Compare commits

..

7 Commits

Author SHA1 Message Date
王性驊 47fb3139c2 feat: add wallet translation 2025-04-18 17:10:40 +08:00
王性驊 4f6262d489 feat: add wallet repo 2025-04-17 17:00:42 +08:00
王性驊 45fab245d9 fix: 修正衝突 2025-04-16 17:33:29 +08:00
王性驊 5214ea2283 feat: add mysql lib 2025-04-16 17:24:54 +08:00
王性驊 59064b0a69 add wallet and user wallet 2025-04-11 17:10:34 +08:00
王性驊 4cb6faefdc feat: update service 2025-04-11 07:23:42 +08:00
王性驊 2f5b04de29 add wallet 2025-04-10 17:33:09 +08:00
40 changed files with 2983 additions and 17 deletions

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `wallet`;

View File

@ -0,0 +1,14 @@
CREATE TABLE `wallet` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID錢包唯一識別',
`brand` VARCHAR(50) NOT NULL COMMENT '品牌/平台(多租戶識別)',
`uid` VARCHAR(64) NOT NULL COMMENT '使用者 UID',
`asset` VARCHAR(32) NOT NULL COMMENT '資產代碼(如 BTC、ETH、GEM_RED 等)',
`balance` DECIMAL(30, 18) UNSIGNED NOT NULL DEFAULT 0 COMMENT '資產餘額',
`type` TINYINT NOT NULL COMMENT '錢包類型',
`create_at` INTEGER NOT NULL DEFAULT 0 COMMENT '建立時間Unix',
`update_at` INTEGER NOT NULL DEFAULT 0 COMMENT '更新時間Unix',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_brand_uid_asset_type` (`brand`, `uid`, `asset`, `type`),
KEY `idx_uid` (`uid`),
KEY `idx_brand` (`brand`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='錢包';

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `transaction`;

View File

@ -0,0 +1,22 @@
CREATE TABLE `transaction` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '交易主鍵 ID自動遞增',
`order_id` VARCHAR(64) DEFAULT NULL COMMENT '關聯的訂單 ID可為空若不是由訂單觸發',
`transaction_id` VARCHAR(64) NOT NULL COMMENT '此筆交易的唯一識別碼(系統內部使用,可為 UUID',
`brand` VARCHAR(50) NOT NULL COMMENT '所屬品牌(支援多品牌場景)',
`uid` VARCHAR(64) NOT NULL COMMENT '交易發起者的 UID',
`to_uid` VARCHAR(64) DEFAULT NULL COMMENT '交易對象的 UID如為轉帳場景',
`type` TINYINT NOT NULL COMMENT '交易類型(如轉帳、入金、出金等,自定義列舉)',
`business_type` TINYINT NOT NULL COMMENT '業務類型(如合約、模擬、一般用途等,數字代碼)',
`crypto` VARCHAR(32) NOT NULL COMMENT '幣種(如 BTC、ETH、USD、TWD 等)',
`amount` DECIMAL(30, 18) NOT NULL COMMENT '本次變動金額(正數為增加,負數為扣減)',
`balance` DECIMAL(30, 18) NOT NULL COMMENT '交易完成後的錢包餘額',
`before_balance` DECIMAL(30, 18) NOT NULL COMMENT '交易前的錢包餘額(方便審計與對帳)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '狀態1: 有效、0: 無效/已取消)',
`create_time` BIGINT NOT NULL DEFAULT 0 COMMENT '建立時間Unix 秒數)',
`due_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期時間(適用於凍結或延後入帳等場景)',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_transaction_id` (`transaction_id`),
KEY `idx_uid` (`uid`),
KEY `idx_order_id` (`order_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易紀錄表';

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `transaction`;

View File

@ -0,0 +1,22 @@
CREATE TABLE `wallet_transaction` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID自動遞增',
`transaction_id` BIGINT NOT NULL COMMENT '交易流水號(可對應某次業務操作,例如同一訂單的多筆變化)',
`order_id` VARCHAR(64) NOT NULL COMMENT '訂單編號(對應實際訂單或業務事件)',
`brand` VARCHAR(50) NOT NULL COMMENT '品牌(多租戶或多平台識別)',
`uid` VARCHAR(64) NOT NULL COMMENT '使用者 UID',
`wallet_type` TINYINT NOT NULL COMMENT '錢包類型(如主錢包、獎勵錢包、凍結錢包等)',
`business_type` TINYINT NOT NULL COMMENT '業務類型(如購物、退款、加值等)',
`asset` VARCHAR(32) NOT NULL COMMENT '資產代號(如 BTC、ETH、GEM_RED、USD 等)',
`amount` DECIMAL(30, 18) NOT NULL COMMENT '變動金額(正數為收入,負數為支出)',
`balance` DECIMAL(30, 18) NOT NULL COMMENT '當前錢包餘額(這筆交易後的餘額快照)',
`create_at` BIGINT NOT NULL DEFAULT 0 COMMENT '建立時間UnixNano紀錄交易發生時間',
PRIMARY KEY (`id`),
KET `idx_uid` (`uid`),
KEY `idx_transaction_id` (`transaction_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_brand` (`brand`),
KEY `idx_wallet_type` (`wallet_type`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci
COMMENT = '錢包資金異動紀錄(每一次交易行為的快照記錄)';

View File

@ -0,0 +1 @@
DROP DATABASE IF EXISTS `digimon_wallet`;

View File

@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS `digimon_wallet`;

56
go.mod
View File

@ -3,73 +3,117 @@ module code.30cm.net/digimon/app-cloudep-wallet-service
go 1.24.2
require (
code.30cm.net/digimon/library-go/errs v1.2.14
github.com/google/uuid v1.6.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.36.0
github.com/zeromicro/go-zero v1.8.2
google.golang.org/grpc v1.71.1
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
require (
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
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
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.0.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-sql-driver/mysql v1.9.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/moby/sys/user v0.1.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/etcd/api/v3 v3.5.15 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect
go.etcd.io/etcd/client/v3 v3.5.15 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.10.0 // indirect

118
go.sum
View File

@ -1,3 +1,15 @@
code.30cm.net/digimon/library-go/errs v1.2.14 h1:Un9wcIIjjJW8D2i0ISf8ibzp9oNT4OqLsaSKW0T4RJU=
code.30cm.net/digimon/library-go/errs v1.2.14/go.mod h1:Hs4v7SbXNggDVBGXSYsFMjkii1qLF+rugrIpWePN4/o=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0=
@ -14,26 +26,48 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
@ -41,6 +75,9 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -52,9 +89,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -66,6 +104,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -83,6 +125,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -90,17 +136,35 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@ -109,6 +173,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
@ -123,6 +189,12 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
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/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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=
@ -133,6 +205,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@ -140,11 +213,19 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeromicro/go-zero v1.8.2 h1:AbJckBoojbr1lqCN1dkvURTIHOau7yvKReEd7ZmjuCk=
github.com/zeromicro/go-zero v1.8.2/go.mod h1:G5dF+jzCEuq0t1j8qdrtVAy30QMgctGcKSfqFIGsvSg=
go.etcd.io/etcd/api/v3 v3.5.15 h1:3KpLJir1ZEBrYuV2v+Twaa/e2MdDCEZ/70H+lzEiwsk=
@ -155,8 +236,10 @@ go.etcd.io/etcd/client/v3 v3.5.15 h1:23M0eY4Fd/inNv1ZfU3AxrbbOdW79r9V9Rl62Nm6ip4
go.etcd.io/etcd/client/v3 v3.5.15/go.mod h1:CLSJxrYjvLtHsrPKsy7LmZEE+DK2ktfd2bN4RhBMwlU=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
@ -169,14 +252,14 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDO
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
go.opentelemetry.io/otel/exporters/zipkin v1.24.0 h1:3evrL5poBuh1KF51D9gO/S+N/1msnm4DaBqs/rpXUqY=
go.opentelemetry.io/otel/exporters/zipkin v1.24.0/go.mod h1:0EHgD8R0+8yRhUYJOGR8Hfg2dpiJQxDOszd5smVO9wM=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@ -192,6 +275,8 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@ -210,14 +295,20 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
@ -259,6 +350,13 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw=
k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
k8s.io/apimachinery v0.29.4 h1:RaFdJiDmuKs/8cm1M6Dh1Kvyh59YQFDcFuFTSmXes6Q=

View File

@ -1,7 +1,22 @@
package config
import "github.com/zeromicro/go-zero/zrpc"
import (
"github.com/zeromicro/go-zero/zrpc"
"time"
)
type Config struct {
zrpc.RpcServerConf
MySQL struct {
UserName string
Password string
Host string
Port string
Database string
MaxIdleConns int
MaxOpenConns int
ConnMaxLifetime time.Duration
LogLevel string
}
}

1
pkg/domain/const.go Normal file
View File

@ -0,0 +1 @@
package domain

View File

@ -0,0 +1,31 @@
package entity
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"github.com/shopspring/decimal"
)
// Transaction 代表一筆錢包交易紀錄(例如充值、扣款、轉帳等)
// 此表記錄所有交易的詳細資訊,包括金額、對象、餘額狀態與交易型態等
type Transaction struct {
ID int64 `gorm:"column:id"` // 交易主鍵 ID自動遞增
OrderID string `gorm:"column:order_id"` // 關聯的訂單 ID可為空若不是由訂單觸發
TransactionID string `gorm:"column:transaction_id"` // 此筆交易的唯一識別碼(系統內部使用,可為 UUID
Brand string `gorm:"column:brand"` // 所屬品牌(支援多品牌場景)
UID string `gorm:"column:uid"` // 交易發起者的 UID
ToUID string `gorm:"column:to_uid"` // 交易對象的 UID如為轉帳場景
TxType wallet.TxType `gorm:"column:type"` // 交易類型(如轉帳、入金、出金等,自定義列舉)
BusinessType int8 `gorm:"column:business_type"` // 業務類型(如合約、模擬、一般用途等,數字代碼)
Asset string `gorm:"column:asset"` // 幣種(如 BTC、ETH、USD、TWD 等)
Amount decimal.Decimal `gorm:"column:amount"` // 本次變動金額(正數為增加,負數為扣減)
PostTransferBalance decimal.Decimal `gorm:"column:post_transfer_balance"` // 交易完成後的錢包餘額
BeforeBalance decimal.Decimal `gorm:"column:before_balance"` // 交易前的錢包餘額(方便審計與對帳)
Status wallet.Enable `gorm:"column:status"` // 狀態1: 有效、0: 無效/已取消)
CreateAt int64 `gorm:"column:create_time;autoCreateTime"` // 建立時間Unix 秒數)
DueTime int64 `gorm:"column:due_time"` // 到期時間(適用於凍結或延後入帳等場景)
}
// TableName 指定 GORM 對應的資料表名稱
func (t *Transaction) TableName() string {
return "transaction"
}

View File

@ -0,0 +1,34 @@
package entity
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"github.com/shopspring/decimal"
)
// 🔐 重點設計理念:
// 避免競爭與資金衝突
// 將錢包用途拆分成不同 type避免一個錢包同時處理可用資金與凍結資金降低邏輯錯誤風險。
//
// 資金安全性與追蹤性強
// 分別記錄「凍結」「未確認」「可用」等不同狀態,方便核對資產總額與可提資金。
//
// 合約與模擬隔離
// 保留合約交易與模擬交易專屬錢包,實現清楚的邊界控制與風險隔離。
//
// 多品牌支援
// Brand 欄位讓平台可以支援多租戶架構(不同品牌有不同的錢包群組)。
type Wallet struct {
ID int64 `gorm:"column:id"` // 主鍵 ID錢包的唯一識別
Brand string `gorm:"column:brand"` // 品牌/平台(區分多租戶、多品牌情境)
UID string `gorm:"column:uid"` // 使用者 UID
Asset string `gorm:"column:asset"` // 資產代號,可為 BTC、ETH、GEM_RED、GEM_BLUE、POINTS ,USD, TWD 等....
Balance decimal.Decimal `gorm:"column:balance"` // 餘額(使用高精度 decimal 避免浮點誤差)
Type wallet.Types `gorm:"column:type"` // 錢包類型
CreateAt int64 `gorm:"column:create_at"` // 建立時間UnixNano timestamp
UpdateAt int64 `gorm:"column:update_at"` // 更新時間UnixNano timestamp
}
func (c *Wallet) TableName() string {
return "wallet" // 對應的資料表名稱
}

View File

@ -0,0 +1,26 @@
package entity
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"github.com/shopspring/decimal"
)
// WalletTransaction 表示錢包的交易紀錄(每一次扣款、加值、入帳、退費都會有一筆紀錄)
type WalletTransaction struct {
ID int64 `gorm:"column:id"` // 主鍵 ID自動遞增
TransactionID int64 `gorm:"column:transaction_id"` // 交易流水號(可對應某次業務操作,例如同一訂單的多筆變化)
OrderID string `gorm:"column:order_id"` // 訂單編號(對應實際訂單或業務事件)
Brand string `gorm:"column:brand"` // 品牌(多租戶或多平台識別)
UID string `gorm:"column:uid"` // 使用者 UID
WalletType wallet.Types `gorm:"column:wallet_type"` // 錢包類型(例如:主錢包、獎勵錢包、凍結錢包等)
BusinessType int8 `gorm:"column:business_type"` // 業務類型(定義本次交易是什麼用途,例如購物、退款、加值等)
Asset string `gorm:"column:asset"` // 資產代號,可為 BTC、ETH、GEM_RED、GEM_BLUE、POINTS ,USD, TWD 等....
Amount decimal.Decimal `gorm:"column:amount"` // 變動金額(正數為收入,負數為支出)
Balance decimal.Decimal `gorm:"column:balance"` // 當前錢包餘額(這筆交易後的餘額快照)
CreateAt int64 `gorm:"column:create_at"` // 建立時間UnixNano紀錄交易發生時間
}
// TableName 指定 GORM 對應的資料表名稱
func (t *WalletTransaction) TableName() string {
return "wallet_transaction"
}

1
pkg/domain/error.go Normal file
View File

@ -0,0 +1 @@
package domain

View File

@ -0,0 +1,6 @@
package repository
import "errors"
var ErrRecordNotFound = errors.New("query record not found")
var ErrBalanceInsufficient = errors.New("balance insufficient") // 餘額不足

View File

@ -0,0 +1,32 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"time"
)
type TransactionRepository interface {
FindByOrderID(ctx context.Context, orderID string) (entity.Transaction, error)
Insert(ctx context.Context, tx *entity.Transaction) error
BatchInsert(ctx context.Context, txs []*entity.Transaction) error
List(ctx context.Context, query TransactionQuery) ([]entity.Transaction, int64, error)
FindByDueTimeRange(ctx context.Context, start time.Time, txType []wallet.TxType) ([]entity.Transaction, error)
UpdateStatusByID(ctx context.Context, id int64, status int) error
ListWalletTransactions(ctx context.Context, uid string, orderIDs []string, walletType wallet.Types) ([]entity.WalletTransaction, error)
}
type TransactionQuery struct {
UID *string
OrderID *string
Brand *string
Assets *string
WalletType *wallet.Types
TxTypes []wallet.TxType
BusinessType []int8
StartTime *int64
EndTime *int64
PageIndex int64
PageSize int64
}

View File

@ -0,0 +1,89 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type Wallet struct {
Brand string // 品牌/平台(區分多租戶、多品牌情境)
UID string // 使用者 UID
Asset string // 資產代號,可為 BTC、ETH、GEM_RED、GEM_BLUE、POINTS ,USD, TWD 等....
Balance decimal.Decimal // 餘額(使用高精度 decimal 避免浮點誤差)
Type wallet.Types // 錢包類型
}
// WalletRepository 是錢包的總入口,負責查詢、初始化與跨帳戶查詢邏輯
type WalletRepository interface {
// NewDB 建立新的 DB 實例(提供給需要操作 tx 的場景)
NewDB() *gorm.DB
// Session 取得單一使用者資產的錢包服務(非交易模式)
//📌 使用場景:
// 用在 不需要交易機制的場景,例如:
// 純查詢錢包餘額查詢快取、Log、統計報表非敏感資料更新失敗可以重試的情境
// ✅ 優點:
// 簡單快速、使用預設的資料庫連線
// 不用包 Transaction ,沒有 Rollback 負擔
Session(uid, asset string) UserWalletService
// SessionWithTx 在資料庫交易內取得錢包服務
// 📌 使用場景:
// 用在 資料需要一致性與原子性保證的邏輯中,例如:
// 加值與扣款(同時操作多個錢包)檢查餘額後立刻寫入交易記錄,綁定訂單與錢包扣款的行為
// 所有與 Add/Commit 有關的處理與其他模組訂單、KYC共用一個 transaction
// ✅ 優點:
// 保障操作過程中不被其他並發操作影響
// 可控制 rollback 行為避免中間失敗導致不一致
// 可組合複雜操作(如:更新錢包同時寫入交易紀錄)
SessionWithTx(db *gorm.DB, uid, asset string) UserWalletService
// Transaction 資料庫交易包裝器(確保交易一致性)
Transaction(fn func(db *gorm.DB) error) error
// InitWallets 初始化使用者的所有錢包類型(如可用、凍結等)
InitWallets(ctx context.Context, param []Wallet) error
// QueryBalances 查詢特定資產的錢包餘額
QueryBalances(ctx context.Context, req BalanceQuery) ([]entity.Wallet, error)
// QueryBalancesByUIDs 查詢多個使用者在特定資產下的錢包餘額
QueryBalancesByUIDs(ctx context.Context, uids []string, req BalanceQuery) ([]entity.Wallet, error)
//// GetDailyTxAmount 查詢使用者今日交易總金額(指定類型與業務)
//GetDailyTxAmount(ctx context.Context, uid string, txTypes []domain.TxType, business wallet.BusinessName) ([]entity.Wallet, error)
}
// BalanceQuery 是查詢餘額時的篩選條件
type BalanceQuery struct {
UID string // 使用者 ID
Asset string // 資產類型Crypto、寶石等
Kinds []wallet.Types // 錢包類型(如可用、凍結等)
}
// UserWalletService 定義了一個「單一使用者、單一資產」的錢包操作合約
type UserWalletService interface {
// InitializeWallets 為新使用者初始化所有錢包類型並寫入資料庫
InitializeWallets(ctx context.Context, brand string) ([]entity.Wallet, error)
// GetAllBalances 查詢此使用者此資產下所有錢包類型的當前餘額
GetAllBalances(ctx context.Context) ([]entity.Wallet, error)
// GetBalancesForTypes 查詢指定錢包類型的一組餘額(不加鎖)
GetBalancesForTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// GetBalancesForUpdate 查詢並鎖定指定錢包類型FOR UPDATE
GetBalancesForUpdate(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// CurrentBalance 從本地緩存取得某種錢包類型的餘額
CurrentBalance(kind wallet.Types) decimal.Decimal
// IncreaseBalance 增加指定錢包類型的餘額,並累積一筆交易紀錄
IncreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error
// DecreaseBalance 減少指定錢包類型的餘額(等同於 IncreaseBalance 的負數版本)
DecreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error
// PrepareTransactions 為所有暫存交易紀錄填上 TXID/OrderID/Brand/BusinessType並回傳可落庫的切片
PrepareTransactions(
txID int64,
orderID, brand string,
businessType wallet.BusinessName,
) []entity.WalletTransaction
// PersistBalances 將本地緩存中所有錢包最終餘額批次寫入資料庫
PersistBalances(ctx context.Context) error
// PersistOrderBalances 將本地緩存中所有訂單相關餘額批次寫入 transaction 表
PersistOrderBalances(ctx context.Context) error
// HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包
HasAvailableBalance(ctx context.Context) (bool, error)
}

View File

@ -0,0 +1,17 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"context"
"gorm.io/gorm"
)
type WalletTransactionRepo interface {
Create(ctx context.Context, db *gorm.DB, tx []entity.WalletTransaction) error
HistoryBalance(ctx context.Context, req HistoryReq) ([]entity.WalletTransaction, error)
}
type HistoryReq struct {
UID string
StartTime int64
}

View File

@ -0,0 +1 @@
package usecase

View File

@ -0,0 +1,91 @@
package usecase
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"github.com/shopspring/decimal"
)
// WalletTransferUseCase 處理錢包資產轉移的核心業務邏輯(支援多種錢包操作)
type WalletTransferUseCase interface {
// Process 處理一次完整的錢包轉帳(含內部轉帳、跨使用者轉帳)
Process(ctx context.Context, req WalletTransferRequest) error
// Withdraw 出金操作(從錢包扣款)
Withdraw(ctx context.Context, tx WalletTransferRequest) error
// Deposit 入金操作(錢包加值)
Deposit(ctx context.Context, tx WalletTransferRequest) error
// DepositUnconfirmed 入金至未確認餘額(如轉帳待確認、預約加值)
DepositUnconfirmed(ctx context.Context, tx WalletTransferRequest) error
// Freeze 將資產從可用餘額移動至凍結餘額(常用於下單等鎖倉需求)
Freeze(ctx context.Context, tx WalletTransferRequest) error
// AppendFreeze 追加凍結金額(已有凍結金額的情況下再次追加)
AppendFreeze(ctx context.Context, tx WalletTransferRequest) error
// UnFreeze 將凍結金額移回可用餘額(如訂單取消)
UnFreeze(ctx context.Context, tx WalletTransferRequest) error
// RollbackFreeze 將凍結金額退回,並作為取消交易的一部分
RollbackFreeze(ctx context.Context, tx WalletTransferRequest) error
// RollbackFreezeAddAvailable 退回凍結金額並補回可用金額(全額退款流程)
RollbackFreezeAddAvailable(ctx context.Context, tx WalletTransferRequest) error
// CancelFreeze 取消凍結操作(不轉移回可用餘額,只做作廢處理)
CancelFreeze(ctx context.Context, tx WalletTransferRequest) error
// Unconfirmed 將未確認金額移回可用餘額(例如:加值完成確認)
Unconfirmed(ctx context.Context, tx WalletTransferRequest) error
// Balance 查詢目前錢包餘額(含可用與不可用)
Balance(ctx context.Context, req BalanceReq) ([]Balance, error)
// HistoryBalance 查詢歷史錢包快照(指定時間前的餘額狀態)
HistoryBalance(ctx context.Context, req BalanceReq) ([]Balance, error)
// BalanceByAssets 查詢使用者特定資產的各錢包類型餘額
BalanceByAssets(ctx context.Context, uid, cryptoCode string, walletTypes []wallet.Types) (BalanceAssetsResp, error)
// CheckBalance 驗證可用餘額是否足夠(通常用於風控前置驗證)
CheckBalance(ctx context.Context, tx WalletTransferRequest) error
// GetTodayWithdraw 查詢使用者今日累積出金額
GetTodayWithdraw(ctx context.Context, uid, toCrypto string) (TodayWithdrawResp, error)
}
// WalletTransferRequest 表示一次錢包轉帳的請求資料
type WalletTransferRequest struct {
ReferenceOrderID string // 對應的訂單編號,可為空(非訂單觸發)
FromUID string // 付款方 UID
ToUID string // 收款方 UID若為錢包內部轉帳可與 FromUID 相同
Asset string // 資產代號(例如 BTC、ETH、TWD 等)
Amount decimal.Decimal // 轉移金額(正數,系統控制正負方向)
PostTransferBalance decimal.Decimal // 轉帳後餘額(可選填,便於日誌追蹤與審計)
TxType wallet.TxType // 交易類型(如入金、出金、轉帳等)
Business wallet.BusinessName // 業務場景類型(如合約、模擬、一般用途等)
Brand string // 所屬品牌(支援多品牌架構)
FromWalletType wallet.Types // 扣款來源錢包類型
ToWalletType wallet.Types // 收款目標錢包類型
}
// BalanceReq 表示查詢錢包餘額的請求參數
type BalanceReq struct {
UID string // 使用者 UID
Asset string // 指定資產(如 BTC
BeforeHour int // 幾小時前的快照(若查歷史快照)
}
// Balance 表示錢包的當前或歷史餘額狀態
type Balance struct {
Asset string // 資產代號
Available decimal.Decimal // 可用餘額
Unavailable UnavailableBalance // 不可用餘額(凍結、未確認)
UpdateTime int64 // 更新時間Unix 時間戳)
}
// UnavailableBalance 表示錢包中不可用的部分
type UnavailableBalance struct {
Freeze decimal.Decimal // 凍結金額(如掛單中)
Unconfirmed decimal.Decimal // 未確認金額(如等待轉帳)
}
// BalanceAssetsResp 表示使用者所有錢包類型在某資產的總和
type BalanceAssetsResp struct {
Balances map[string]decimal.Decimal // 各錢包類型對應的餘額
Asset string // 查詢的資產代號
}
// TodayWithdrawResp 表示使用者今天的累積出金結果
type TodayWithdrawResp struct {
Withdraw decimal.Decimal // 今日總出金金額
Asset string // 資產代號
}

View File

@ -0,0 +1,7 @@
package wallet
type BusinessName string
func (b BusinessName) ToInt8() int8 {
return int8(0)
}

View File

@ -0,0 +1,16 @@
package wallet
type Enable int8
const (
EnableTrue Enable = 1
EnableFalse Enable = 2
)
func (e Enable) ToINT8() int8 {
return int8(e)
}
func (e Enable) ToBool() bool {
return e == EnableTrue
}

View File

@ -0,0 +1,35 @@
package wallet
type TxType int8
// 交易類型
const (
// Deposit 充值(增加可用餘額)
Deposit TxType = iota + 1
// Withdraw 提現(減少可用餘額)
Withdraw
// Freeze 凍結(減少可用餘額,加在凍結餘額)
Freeze
// UnFreeze 解凍(減少凍結餘額)
UnFreeze
// RollbackFreeze (減少凍結餘額,加回可用餘額,不可指定金額,根據訂單)
RollbackFreeze
// Unconfirmed 限制(減少凍結餘額,加別人限制餘額)
Unconfirmed
// CancelFreeze 取消凍結(減少凍結餘額,加回可用餘額,,可指定金額)
CancelFreeze
// DepositUnconfirmed 充值(增加限制餘額)
DepositUnconfirmed
// AppendFreeze 追加凍結(減少可用餘額,加在凍結餘額)
AppendFreeze
// RollbackFreezeAddAvailable (rollback凍結餘額指定金額加回可用餘額)
RollbackFreezeAddAvailable
// Distribution 平台分發(活動送錢給玩家)
Distribution
// SystemTransfer 系統劃轉
SystemTransfer
)
func (t TxType) ToInt() int {
return int(t)
}

View File

@ -0,0 +1,17 @@
package wallet
type Types int8
const (
TypeAvailable Types = iota + 1 // 可動用金額(使用者可以自由花用的餘額)
TypeFreeze // 被凍結金額(交易進行中或風控鎖住的金額)
TypeUnconfirmed // 未確認金額(交易已送出但區塊鏈尚未確認)
// 以下為進階用途:合約或模擬交易錢包
TypeContractAvailable // 合約系統的可用金額
TypeContractFreeze // 合約中被凍結的金額
TypeSimulationAvailable // 模擬交易可用金額(例如沙盒環境)
TypeSimulationFreeze // 模擬交易凍結金額
)
var AllTypes = []Types{TypeAvailable, TypeFreeze, TypeUnconfirmed}

View File

@ -0,0 +1,16 @@
package sql_client
import "time"
type Config struct {
User string
Password string
Host string
Port string
Database string
MaxIdleConns int
MaxOpenConns int
ConnMaxLifetime time.Duration
InterpolateParams bool
LogLevel string
}

View File

@ -0,0 +1,10 @@
package sql_client
import "time"
const (
defaultMaxOpenConns = 25
defaultMaxIdleConns = 25
defaultMaxLifeTime = 5 * time.Minute
defaultSlowSQLThreshold = 200 * time.Millisecond
)

View File

@ -0,0 +1,30 @@
package sql_client
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/internal/config"
"fmt"
"gorm.io/gorm"
)
func NewMySQLClient(conf config.Config) (*gorm.DB, error) {
dbConf := &Config{
User: conf.MySQL.UserName,
Password: conf.MySQL.Password,
Host: conf.MySQL.Host,
Port: conf.MySQL.Port,
Database: conf.MySQL.Database,
MaxIdleConns: conf.MySQL.MaxIdleConns,
MaxOpenConns: conf.MySQL.MaxOpenConns,
ConnMaxLifetime: conf.MySQL.ConnMaxLifetime,
InterpolateParams: true,
}
dbInit := New(dbConf, WithLogLevel(conf.MySQL.LogLevel))
orm, err := dbInit.Conn()
if err != nil {
return nil, fmt.Errorf("failed to initiate db connection pool: %w", err)
}
return orm, nil
}

View File

@ -0,0 +1,84 @@
package sql_client
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"log"
"os"
)
type MySQL struct {
mysqlConf gorm.Dialector
gormConf *gorm.Config
serverConf *Config
}
var defaultConfig = logger.Config{
SlowThreshold: defaultSlowSQLThreshold,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
Colorful: true,
}
// New initializes a MysqlInit using the provided Config and options. If
// opts is not provided it will initialize MysqlInit with default configuration.
func New(conf *Config, opts ...Option) *MySQL {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local&interpolateParams=%t",
conf.User, conf.Password, conf.Host, conf.Port, conf.Database, conf.InterpolateParams)
loggerDefault := logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), defaultConfig)
mysqli := &MySQL{
mysqlConf: mysql.Open(dsn),
gormConf: &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true, // use singular table name
},
Logger: loggerDefault,
},
serverConf: conf,
}
for _, opt := range opts {
opt(mysqli)
}
return mysqli
}
// Conn initiates connection to database and return a gorm.DB.
func (mysqli *MySQL) Conn() (*gorm.DB, error) {
db, err := gorm.Open(mysqli.mysqlConf, mysqli.gormConf)
if err != nil {
return nil, fmt.Errorf("gorm open error: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get connect pool error :%w", err)
}
maxIdleConns := defaultMaxIdleConns
if mysqli.serverConf.MaxIdleConns > 0 {
maxIdleConns = mysqli.serverConf.MaxIdleConns
}
maxOpenConns := defaultMaxOpenConns
if mysqli.serverConf.MaxOpenConns > 0 {
maxOpenConns = mysqli.serverConf.MaxOpenConns
}
maxLifeTime := defaultMaxLifeTime
if mysqli.serverConf.ConnMaxLifetime > 0 {
maxLifeTime = mysqli.serverConf.ConnMaxLifetime
}
sqlDB.SetMaxIdleConns(maxIdleConns)
sqlDB.SetMaxOpenConns(maxOpenConns)
sqlDB.SetConnMaxLifetime(maxLifeTime)
return db, nil
}

View File

@ -0,0 +1,41 @@
package sql_client
import (
"gorm.io/gorm/logger"
"strings"
)
type Option func(*MySQL)
// WithLogLevel set gorm log level.
func WithLogLevel(level string) Option {
return func(mysqli *MySQL) {
var gormLogLevel logger.LogLevel
switch strings.ToLower(level) {
case "panic", "fatal", "error":
gormLogLevel = logger.Error
case "warn", "warning":
gormLogLevel = logger.Warn
case "info", "debug", "trace":
gormLogLevel = logger.Info
case "silent":
gormLogLevel = logger.Silent
default:
gormLogLevel = logger.Silent
}
newLogger := mysqli.gormConf.Logger.LogMode(gormLogLevel)
mysqli.gormConf.Logger = newLogger
}
}
// WithPerformance disable default transaction and create a prepared statement
// related doc: https://gorm.io/docs/performance.html
func WithPerformance() Option {
return func(mysqli *MySQL) {
mysqli.gormConf.SkipDefaultTransaction = true
mysqli.gormConf.PrepareStmt = true
}
}

View File

@ -0,0 +1,65 @@
package repository
import (
"context"
"fmt"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
MySQLUser = "root"
MySQLPassword = "password"
MySQLDatabase = "testdb"
MySQLPort = "3306"
)
// 啟動 MySQL container
func startMySQLContainer() (host string, port string, dsn string, tearDown func(), err error) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "mysql:8.0",
Env: map[string]string{
"MYSQL_ROOT_PASSWORD": MySQLPassword,
"MYSQL_DATABASE": MySQLDatabase,
},
ExposedPorts: []string{"3306/tcp"},
WaitingFor: wait.ForListeningPort("3306/tcp"),
}
mysqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return "", "", "", nil, err
}
mappedPort, err := mysqlC.MappedPort(ctx, "3306")
if err != nil {
return "", "", "", nil, err
}
containerHost, err := mysqlC.Host(ctx)
if err != nil {
return "", "", "", nil, err
}
// 組成 DSNData Source Name
dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
MySQLUser,
MySQLPassword,
containerHost,
mappedPort.Port(),
MySQLDatabase,
)
tearDown = func() {
_ = mysqlC.Terminate(ctx)
}
fmt.Printf("MySQL ready at: %s\n", dsn)
return containerHost, mappedPort.Port(), dsn, tearDown, nil
}

View File

@ -0,0 +1,219 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"database/sql"
"errors"
"fmt"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
// WalletService 代表一個使用者在某資產上的錢包服務,
// 負責讀取/寫入資料庫並在記憶體暫存變動
type WalletService struct {
db *gorm.DB
uid string // 使用者識別碼
asset string // 資產代號 (如 BTC、ETH、TWD)
localBalances map[wallet.Types]entity.Wallet // 暫存各類型錢包當前餘額
localOrderBalances map[int64]decimal.Decimal // 暫存各訂單變動後的餘額
transactions []entity.WalletTransaction // 暫存所有尚未落庫的錢包交易紀錄
}
// NewWalletService 建立一個 WalletService 實例
func NewWalletService(db *gorm.DB, uid, asset string) repository.UserWalletService {
return &WalletService{
db: db,
uid: uid,
asset: asset,
localBalances: make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)),
localOrderBalances: make(map[int64]decimal.Decimal, len(wallet.AllTypes)),
}
}
// InitializeWallets 啟動時為新使用者初始化所有類型錢包,並寫入資料庫
func (s *WalletService) InitializeWallets(ctx context.Context, brand string) ([]entity.Wallet, error) {
var wallets []entity.Wallet
for _, t := range wallet.AllTypes {
wallets = append(wallets, entity.Wallet{
Brand: brand,
UID: s.uid,
Asset: s.asset,
Balance: decimal.Zero,
Type: t,
})
}
if err := s.db.WithContext(ctx).Create(&wallets).Error; err != nil {
return nil, err
}
// 將初始化後的錢包資料寫入本地緩存
for _, w := range wallets {
s.localBalances[w.Type] = w
}
return wallets, nil
}
// GetAllBalances 查詢該使用者某資產所有錢包類型當前餘額
func (s *WalletService) GetAllBalances(ctx context.Context) ([]entity.Wallet, error) {
var result []entity.Wallet
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return nil, err
}
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
// GetBalancesForTypes 查詢指定類型的錢包餘額,不上鎖
func (s *WalletService) GetBalancesForTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var result []entity.Wallet
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type IN ?", kinds).
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return nil, translateNotFound(err)
}
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
// GetBalancesForUpdate 查詢並鎖定指定類型的錢包 (FOR UPDATE)
func (s *WalletService) GetBalancesForUpdate(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var result []entity.Wallet
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type IN ?", kinds).
Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return nil, translateNotFound(err)
}
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
// CurrentBalance 從緩存中取得某種類型錢包的當前餘額
func (s *WalletService) CurrentBalance(kind wallet.Types) decimal.Decimal {
if w, ok := s.localBalances[kind]; ok {
return w.Balance
}
return decimal.Zero
}
// IncreaseBalance 在本地緩存新增餘額,並記錄一筆 WalletTransaction
func (s *WalletService) IncreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error {
w, ok := s.localBalances[kind]
if !ok {
return repository.ErrRecordNotFound
}
w.Balance = w.Balance.Add(amount)
if w.Balance.LessThan(decimal.Zero) {
return repository.ErrBalanceInsufficient
}
s.transactions = append(s.transactions, entity.WalletTransaction{
OrderID: orderID,
UID: s.uid,
WalletType: kind,
Asset: s.asset,
Amount: amount,
Balance: w.Balance,
})
s.localBalances[kind] = w
return nil
}
// DecreaseBalance 本質上是 IncreaseBalance 的負數版本
func (s *WalletService) DecreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error {
return s.IncreaseBalance(kind, orderID, amount.Neg())
}
// PrepareTransactions 為每筆暫存的 WalletTransaction 填入共用欄位 (txID, brand, businessType)
// 並回傳完整可落庫的切片
func (s *WalletService) PrepareTransactions(
txID int64,
orderID, brand string,
businessType wallet.BusinessName,
) []entity.WalletTransaction {
for i := range s.transactions {
s.transactions[i].TransactionID = txID
s.transactions[i].OrderID = orderID
s.transactions[i].Brand = brand
s.transactions[i].BusinessType = businessType.ToInt8()
}
return s.transactions
}
// PersistBalances 寫入本地緩存中所有錢包的最終餘額到資料庫
func (s *WalletService) PersistBalances(ctx context.Context) error {
return s.db.Transaction(func(tx *gorm.DB) error {
for _, w := range s.localBalances {
if err := tx.WithContext(ctx).
Model(&entity.Wallet{}).
Where("id = ?", w.ID).
UpdateColumns(map[string]interface{}{
"balance": w.Balance,
"update_at": time.Now().Unix(),
}).Error; err != nil {
return fmt.Errorf("更新錢包餘額失敗 (id=%d): %w", w.ID, err)
}
}
return nil
}, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
// PersistOrderBalances 寫入所有訂單錢包的最終餘額到 transaction 表
func (s *WalletService) PersistOrderBalances(ctx context.Context) error {
return s.db.Transaction(func(tx *gorm.DB) error {
for id, bal := range s.localOrderBalances {
if err := tx.WithContext(ctx).
Model(&entity.Transaction{}).
Where("id = ?", id).
Update("post_transfer_balance", bal).Error; err != nil {
return fmt.Errorf("更新訂單錢包餘額失敗 (id=%d): %w", id, err)
}
}
return nil
}, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
func (s *WalletService) HasAvailableBalance(ctx context.Context) (bool, error) {
var exists bool
err := s.db.WithContext(ctx).
Model(&entity.Wallet{}).
Select("1").
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type = ?", wallet.TypeAvailable).
Limit(1).
Scan(&exists).Error
if err != nil {
return false, err
}
return exists, nil
}
// translateNotFound 將 GORM 的 RecordNotFound 轉為自訂錯誤
func translateNotFound(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return repository.ErrRecordNotFound
}
return err
}

File diff suppressed because it is too large Load Diff

113
pkg/repository/wallet.go Normal file
View File

@ -0,0 +1,113 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"context"
"database/sql"
"gorm.io/gorm"
)
type WalletRepositoryParam struct {
DB *gorm.DB `name:"dbM"`
}
type WalletRepository struct {
WalletRepositoryParam
}
func MustCategoryRepository(param WalletRepositoryParam) repository.WalletRepository {
return &WalletRepository{
param,
}
}
func (repo *WalletRepository) NewDB() *gorm.DB {
return repo.DB.Begin()
}
// Transaction 指 DB 事務,非記錄一筆交易
func (repo *WalletRepository) Transaction(fn func(db *gorm.DB) error) error {
db := repo.DB.Begin(&sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: false,
})
defer db.Rollback()
if err := fn(db); err != nil {
return err
}
if err := db.Commit().Error; err != nil {
return err
}
return nil
}
func (repo *WalletRepository) Session(uid, asset string) repository.UserWalletService {
return NewWalletService(repo.DB, uid, asset)
}
func (repo *WalletRepository) SessionWithTx(db *gorm.DB, uid, asset string) repository.UserWalletService {
return NewWalletService(db, uid, asset)
}
func (repo *WalletRepository) InitWallets(ctx context.Context, param []repository.Wallet) error {
wallets := make([]entity.Wallet, 0, len(param))
for _, t := range param {
wallets = append(wallets, entity.Wallet{
Brand: t.Brand,
UID: t.UID,
Asset: t.Asset,
Balance: t.Balance,
Type: t.Type,
})
}
if err := repo.DB.Create(&wallets).WithContext(ctx).Error; err != nil {
return err
}
return nil
}
func (repo *WalletRepository) QueryBalances(ctx context.Context, req repository.BalanceQuery) ([]entity.Wallet, error) {
var data []entity.Wallet
s := repo.DB.WithContext(ctx).
Select("id, asset, balance, type, update_at").
Where("uid = ?", req.UID)
if req.Asset != "" {
s = s.Where("asset = ?", req.Asset)
}
err := s.Find(&data).Error
if err != nil {
return nil, err
}
return data, nil
}
func (repo *WalletRepository) QueryBalancesByUIDs(ctx context.Context, uids []string, req repository.BalanceQuery) ([]entity.Wallet, error) {
var data []entity.Wallet
query := repo.DB.WithContext(ctx).
Select("uid, asset, balance, type, update_at").
Where("uid IN ?", uids)
if req.Asset != "" {
query = query.Where("asset = ?", req.Asset)
}
if len(req.Kinds) > 0 {
query = query.Where("type IN ?", req.Kinds)
}
err := query.Find(&data).Error
if err != nil {
return nil, err
}
return data, nil
}

View File

@ -0,0 +1,355 @@
package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/internal/config"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/lib/sql_client"
"context"
"errors"
"fmt"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"testing"
"time"
)
func SetupTestWalletRepository() (repository.WalletRepository, *gorm.DB, func(), error) {
host, port, _, tearDown, err := startMySQLContainer()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to start MySQL container: %w", err)
}
conf := config.Config{
MySQL: struct {
UserName string
Password string
Host string
Port string
Database string
MaxIdleConns int
MaxOpenConns int
ConnMaxLifetime time.Duration
LogLevel string
}{
UserName: MySQLUser,
Password: MySQLPassword,
Host: host,
Port: port,
Database: MySQLDatabase,
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: 300,
LogLevel: "info",
},
}
db, err := sql_client.NewMySQLClient(conf)
if err != nil {
tearDown()
return nil, nil, nil, fmt.Errorf("failed to create db client: %w", err)
}
repo := MustCategoryRepository(WalletRepositoryParam{DB: db})
return repo, db, tearDown, nil
}
func TestWalletRepository_InitWallets(t *testing.T) {
repo, db, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
// 🔽 這裡加上建表 SQL
createTableSQL := `
CREATE TABLE IF NOT EXISTS wallet (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID錢包唯一識別',
brand VARCHAR(50) NOT NULL COMMENT '品牌/平台多租戶識別',
uid VARCHAR(64) NOT NULL COMMENT '使用者 UID',
asset VARCHAR(32) NOT NULL COMMENT '資產代碼 BTCETHGEM_RED ',
balance DECIMAL(30, 18) UNSIGNED NOT NULL DEFAULT 0 COMMENT '資產餘額',
type TINYINT NOT NULL COMMENT '錢包類型',
create_at INTEGER NOT NULL DEFAULT 0 COMMENT '建立時間Unix',
update_at INTEGER NOT NULL DEFAULT 0 COMMENT '更新時間Unix',
PRIMARY KEY (id),
UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type),
KEY idx_uid (uid),
KEY idx_brand (brand)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='錢包';
`
err = db.Exec(createTableSQL).Error
assert.NoError(t, err)
tests := []struct {
name string
param []repository.Wallet
wantErr bool
}{
{
name: "insert single wallet",
param: []repository.Wallet{
{
Brand: "test-brand",
UID: "user001",
Asset: "BTC",
Balance: decimal.NewFromFloat(10.5),
Type: wallet.TypeAvailable,
},
},
wantErr: false,
},
{
name: "insert multiple wallets",
param: []repository.Wallet{
{
Brand: "test-brand",
UID: "user002",
Asset: "ETH",
Balance: decimal.NewFromFloat(5),
Type: wallet.TypeAvailable,
},
{
Brand: "test-brand",
UID: "user002",
Asset: "ETH",
Balance: decimal.NewFromFloat(1.2),
Type: wallet.TypeFreeze,
},
},
wantErr: false,
},
{
name: "insert duplicate primary key (should fail if unique constraint)",
param: []repository.Wallet{
{
Brand: "test-brand",
UID: "user001",
Asset: "BTC",
Balance: decimal.NewFromFloat(1),
Type: wallet.TypeAvailable, // 與第一筆測試資料相同
},
},
wantErr: true, // 預期會違反 UNIQUE constraint
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := repo.InitWallets(context.Background(), tt.param)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestWalletRepository_QueryBalances(t *testing.T) {
repo, db, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
// 🔽 這裡加上建表 SQL
createTableSQL := `
CREATE TABLE IF NOT EXISTS wallet (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID錢包唯一識別',
brand VARCHAR(50) NOT NULL COMMENT '品牌/平台多租戶識別',
uid VARCHAR(64) NOT NULL COMMENT '使用者 UID',
asset VARCHAR(32) NOT NULL COMMENT '資產代碼 BTCETHGEM_RED ',
balance DECIMAL(30, 18) UNSIGNED NOT NULL DEFAULT 0 COMMENT '資產餘額',
type TINYINT NOT NULL COMMENT '錢包類型',
create_at INTEGER NOT NULL DEFAULT 0 COMMENT '建立時間Unix',
update_at INTEGER NOT NULL DEFAULT 0 COMMENT '更新時間Unix',
PRIMARY KEY (id),
UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type),
KEY idx_uid (uid),
KEY idx_brand (brand)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='錢包';
`
err = db.Exec(createTableSQL).Error
assert.NoError(t, err)
ctx := context.Background()
// 建立初始化錢包資料
wallets := []repository.Wallet{
{Brand: "brand1", UID: "user1", Asset: "BTC", Balance: decimal.NewFromFloat(1.5), Type: wallet.TypeAvailable},
{Brand: "brand1", UID: "user1", Asset: "ETH", Balance: decimal.NewFromFloat(2.5), Type: wallet.TypeFreeze},
{Brand: "brand1", UID: "user2", Asset: "BTC", Balance: decimal.NewFromFloat(3.0), Type: wallet.TypeAvailable},
}
err = repo.InitWallets(ctx, wallets)
assert.NoError(t, err)
tests := []struct {
name string
query repository.BalanceQuery
expected int
}{
{
name: "Query all by UID",
query: repository.BalanceQuery{
UID: "user1",
},
expected: 2,
},
{
name: "Query by UID and Asset",
query: repository.BalanceQuery{
UID: "user1",
Asset: "BTC",
},
expected: 1,
},
{
name: "Query by UID with non-existing asset",
query: repository.BalanceQuery{
UID: "user1",
Asset: "GEM_RED",
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := repo.QueryBalances(ctx, tt.query)
assert.NoError(t, err)
assert.Len(t, got, tt.expected)
})
}
}
func TestWalletRepository_QueryBalancesByUIDs(t *testing.T) {
repo, db, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
// 🔽 這裡加上建表 SQL
createTableSQL := `
CREATE TABLE IF NOT EXISTS wallet (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID錢包唯一識別',
brand VARCHAR(50) NOT NULL COMMENT '品牌/平台多租戶識別',
uid VARCHAR(64) NOT NULL COMMENT '使用者 UID',
asset VARCHAR(32) NOT NULL COMMENT '資產代碼 BTCETHGEM_RED ',
balance DECIMAL(30, 18) UNSIGNED NOT NULL DEFAULT 0 COMMENT '資產餘額',
type TINYINT NOT NULL COMMENT '錢包類型',
create_at INTEGER NOT NULL DEFAULT 0 COMMENT '建立時間Unix',
update_at INTEGER NOT NULL DEFAULT 0 COMMENT '更新時間Unix',
PRIMARY KEY (id),
UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type),
KEY idx_uid (uid),
KEY idx_brand (brand)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='錢包';
`
err = db.Exec(createTableSQL).Error
assert.NoError(t, err)
ctx := context.Background()
// 初始化錢包資料
initData := []repository.Wallet{
{Brand: "brand1", UID: "user1", Asset: "BTC", Balance: decimal.NewFromFloat(1.5), Type: wallet.TypeAvailable},
{Brand: "brand1", UID: "user2", Asset: "BTC", Balance: decimal.NewFromFloat(2.0), Type: wallet.TypeAvailable},
{Brand: "brand1", UID: "user2", Asset: "ETH", Balance: decimal.NewFromFloat(3.0), Type: wallet.TypeFreeze},
{Brand: "brand1", UID: "user3", Asset: "BTC", Balance: decimal.NewFromFloat(4.5), Type: wallet.TypeAvailable},
}
err = repo.InitWallets(ctx, initData)
assert.NoError(t, err)
tests := []struct {
name string
uids []string
query repository.BalanceQuery
expected int
}{
{
name: "Query all users with BTC",
uids: []string{"user1", "user2", "user3"},
query: repository.BalanceQuery{Asset: "BTC"},
expected: 3,
},
{
name: "Query specific users with filter by type",
uids: []string{"user2"},
query: repository.BalanceQuery{Kinds: []wallet.Types{wallet.TypeAvailable}},
expected: 1,
},
{
name: "Query with no matches",
uids: []string{"user2"},
query: repository.BalanceQuery{Asset: "DOGE"},
expected: 0,
},
{
name: "Query all for user2",
uids: []string{"user2"},
query: repository.BalanceQuery{},
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := repo.QueryBalancesByUIDs(ctx, tt.uids, tt.query)
assert.NoError(t, err)
assert.Len(t, result, tt.expected)
})
}
}
func TestWalletRepository_NewDB(t *testing.T) {
repo, _, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
tx := repo.NewDB()
assert.NotNil(t, tx)
assert.NoError(t, tx.Commit().Error)
}
func TestWalletRepository_Transaction(t *testing.T) {
repo, _, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
t.Run("commit success", func(t *testing.T) {
err := repo.Transaction(func(tx *gorm.DB) error {
return nil // 模擬成功流程
})
assert.NoError(t, err)
})
t.Run("rollback due to fn error", func(t *testing.T) {
customErr := errors.New("rollback me")
err := repo.Transaction(func(tx *gorm.DB) error {
return customErr
})
assert.ErrorIs(t, err, customErr)
})
}
func TestWalletRepository_Session(t *testing.T) {
repo, _, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
sess := repo.Session("userX", "BTC")
assert.NotNil(t, sess)
}
func TestWalletRepository_SessionWithTx(t *testing.T) {
repo, _, tearDown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer tearDown()
tx := repo.NewDB()
defer tx.Rollback()
sess := repo.SessionWithTx(tx, "userY", "ETH")
assert.NotNil(t, sess)
}

124
pkg/usecase/wallet.go Normal file
View File

@ -0,0 +1,124 @@
package usecase
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/usecase"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"code.30cm.net/digimon/library-go/errs"
"context"
"sync"
)
type WalletUseCaseParam struct {
WalletRepo repository.WalletRepository
TransactionRepo repository.TransactionRepository
WalletTransactionRepo repository.WalletTransactionRepo
}
type WalletUseCase struct {
WalletUseCaseParam
// 內存讀寫所記錄有哪些玩家已經確認有存在錢包過了,減少確認錢包是否存在頻率
sync.RWMutex
existUIDAsset map[string]struct{}
}
func (use *WalletUseCase) Process(ctx context.Context, req usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
// Withdraw 提幣
// 1. 新增一筆提幣交易
// 2. 錢包減少可用餘額
// 3. 錢包變化新增一筆減少可用餘額資料
func (use *WalletUseCase) Withdraw(ctx context.Context, tx usecase.WalletTransferRequest) error {
if !tx.Amount.IsPositive() {
return errs.InvalidRange("failed to get correct amount")
}
tx.TxType = wallet.Withdraw
return use.ProcessTransaction(ctx, tx, userWalletFlow{
UID: tx.FromUID,
Asset: tx.Asset,
Actions: []walletActionOption{use.withLockAvailable(), use.withSubAvailable()}, //use.lockAvailable(), use.subAvailable()
})
}
func (use *WalletUseCase) Deposit(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) DepositUnconfirmed(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) Freeze(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) AppendFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) UnFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) RollbackFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) RollbackFreezeAddAvailable(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) CancelFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) Unconfirmed(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) Balance(ctx context.Context, req usecase.BalanceReq) ([]usecase.Balance, error) {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) HistoryBalance(ctx context.Context, req usecase.BalanceReq) ([]usecase.Balance, error) {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) BalanceByAssets(ctx context.Context, uid, cryptoCode string, walletTypes []wallet.Types) (usecase.BalanceAssetsResp, error) {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) CheckBalance(ctx context.Context, tx usecase.WalletTransferRequest) error {
//TODO implement me
panic("implement me")
}
func (use *WalletUseCase) GetTodayWithdraw(ctx context.Context, uid, toCrypto string) (usecase.TodayWithdrawResp, error) {
//TODO implement me
panic("implement me")
}
func MustWalletUseCase(param WalletUseCaseParam) usecase.WalletTransferUseCase {
return &WalletUseCase{
WalletUseCaseParam: param,
existUIDAsset: make(map[string]struct{}),
}
}

View File

@ -0,0 +1,79 @@
package usecase
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/usecase"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"errors"
"fmt"
)
type uidAssetKey struct {
uid string
asset string
}
// walletActionOption 表示一個「錢包操作函式」,可在錢包流程中插入自定義動作(例如:扣款、加值、凍結)
type walletActionOption func(
ctx context.Context,
tx *usecase.WalletTransferRequest,
wallet repository.UserWalletService,
) error
// withLockAvailable 鎖定用戶可用餘額
func (use *WalletUseCase) withLockAvailable() walletActionOption {
return func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error {
uidAsset := uidAssetKey{
uid: tx.FromUID,
asset: tx.Asset,
}
if !use.checkWalletExistence(uidAsset) {
// 找不到錢包存不存在
wStatus, err := w.HasAvailableBalance(ctx)
if err != nil {
return fmt.Errorf("failed to check wallet: %w", err)
}
// 錢包不存在要做新增
if !wStatus {
//// 是合約模擬交易或帳變且錢包不存在才建立錢包
//if !(tx.Business == wa.ContractSimulationBusinessTypeBusinessName || tx.BusinessType == domain.DistributionBusinessTypeBusinessName) {
// // 新增錢包有命中 UK 不需要額外上鎖
// return use.translateError(err)
//}
if _, err := w.InitializeWallets(ctx, tx.Brand); err != nil {
return err
}
return nil
}
use.markWalletAsExisting(uidAsset)
}
_, err := w.GetBalancesForUpdate(ctx, []wallet.Types{wallet.TypeAvailable})
if err != nil {
return err
}
return nil
}
}
// subAvailable 減少用戶可用餘額
func (use *WalletUseCase) withSubAvailable() walletActionOption {
return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error {
if err := w.DecreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil {
if errors.Is(err, repository.ErrBalanceInsufficient) {
// todo 錯誤要看怎麼給(餘額不足)
return fmt.Errorf("balance insufficient")
}
return err
}
return nil
}
}

View File

@ -0,0 +1,127 @@
package usecase
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/usecase"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
repo "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/repository"
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
// userWalletFlow 表示一組要對某使用者、某資產執行的錢包操作(動作串列)
// 這些操作通常會在轉帳流程中依序套用,例如:從主錢包扣款 → 加到對方凍結錢包
type userWalletFlow struct {
UID string // 目標使用者 UID
Asset string // 目標資產代號(如 BTC、ETH、TWD
Actions []walletActionOption // 要依序執行的錢包操作
}
// ProcessTransaction 處理一次完整的「錢包 + 訂單」交易流程:
// 1. 透過 Repository 開啟 DB 事務
// 2. 依序對每個 userWalletFlow 建立對應的 UserWalletService 實例
// 3. 依序執行每個 flow.Actions扣款、加值、凍結、解凍…
// 4. 建立一條 Transaction 記錄並寫進 transactionRepository
// 5. 將所有 walletTransactions 寫進 walletTransactionRepository
// 6. 最後在同一事務中執行每個 wallet 的 Execute / ExecuteOrder
// 7. 特別注意 flow 會按照順序做,所以順序是重要的
func (use *WalletUseCase) ProcessTransaction(
ctx context.Context,
req usecase.WalletTransferRequest,
flows ...userWalletFlow,
) error {
return use.WalletRepo.Transaction(func(db *gorm.DB) error {
// 暫存所有建立好的 UserWalletService
wallets := make([]repository.UserWalletService, 0, len(flows))
// flows 會按照順序做.順序是重要的
for _, flow := range flows {
// 1⃣ 建立針對該使用者+資產的 UserWalletService
wSvc := repo.NewWalletService(db, flow.UID, flow.Asset)
// 2⃣ 依序執行所有定義好的錢包操作
for _, action := range flow.Actions {
if err := action(ctx, &req, wSvc); err != nil {
return err
}
}
wallets = append(wallets, wSvc)
}
// 3⃣ 準備寫入 Transaction 主檔
txRecord := &entity.Transaction{
OrderID: req.ReferenceOrderID,
TransactionID: uuid.New().String(),
UID: req.FromUID,
ToUID: req.ToUID,
Asset: req.Asset,
TxType: req.TxType,
Amount: req.Amount,
Brand: req.Brand,
PostTransferBalance: req.PostTransferBalance,
BusinessType: req.Business.ToInt8(),
Status: wallet.EnableTrue,
DueTime: 0,
}
// 4⃣ TODO 計算 DueTime T+N 結算時間)
// 5⃣ 寫入 Transaction 主檔
if err := use.TransactionRepo.Insert(ctx, txRecord); err != nil {
return fmt.Errorf("TransactionRepo.Insert 失敗: %w", err)
}
// 6⃣ 聚合所有 wallet 內的交易歷程
var walletTxs []entity.WalletTransaction
for _, w := range wallets {
walletTxs = append(
walletTxs,
w.PrepareTransactions(
txRecord.ID,
txRecord.OrderID,
req.Brand,
req.Business,
)...,
)
}
// 7⃣ 批次寫入所有 WalletTransaction
if err := use.WalletTransactionRepo.Create(ctx, db, walletTxs); err != nil {
return fmt.Errorf("WalletTransactionRepository.Create 失敗: %w", err)
}
// 8⃣ 最後才真正把錢包的餘額更新到資料庫(同一事務)
for _, wSvc := range wallets {
if err := wSvc.PersistBalances(ctx); err != nil {
return err
}
if err := wSvc.PersistOrderBalances(ctx); err != nil {
return err
}
}
return nil
})
}
// checkWalletExistence 檢查錢包是否存在於內存中
func (use *WalletUseCase) checkWalletExistence(uidAsset uidAssetKey) bool {
use.RLock()
defer use.RUnlock()
rk := fmt.Sprintf("%s-%s", uidAsset.uid, uidAsset.asset)
_, exists := use.existUIDAsset[rk]
return exists
}
// markWalletAsExisting 標記錢包為存在
func (use *WalletUseCase) markWalletAsExisting(uidAsset uidAssetKey) {
use.Lock()
defer use.Unlock()
rk := fmt.Sprintf("%s-%s", uidAsset.uid, uidAsset.asset)
use.existUIDAsset[rk] = struct{}{}
}

54
readme.md Normal file
View File

@ -0,0 +1,54 @@
// 充值-增加可用餘額
v1.POST("deposit", wallet.Deposit)
// 充值-增加限制餘額
v1.POST("deposit/unconfirmed", wallet.DepositUnconfirmed)
// 提現-減少可用餘額
v1.POST("withdraw", wallet.Withdraw)
// 凍結-減少可用餘額,加在凍結餘額
v1.POST("freeze", wallet.Freeze)
// 追加凍結(原凍結金額上)-減少可用餘額,加在凍結餘額
v1.POST("freeze/append", wallet.AppendFreeze)
// 解凍-減少凍結餘額
v1.POST("unfreeze", wallet.UnFreeze)
// rollback凍結-減少凍結餘額,加回可用餘額,不可指定金額
v1.POST("freeze/rollback", wallet.RollbackFreeze)
// rollback凍結-rollback凍結餘額指定金額加回可用餘額
v1.POST("freeze/rollback/add", wallet.RollbackFreezeAddAvailable)
// 取消凍結-減少凍結餘額,加回可用餘額,可指定金額
v1.POST("freeze/cancel", wallet.CancelFreeze)
// 限制-減少凍結餘額,加別人限制餘額
v1.POST("unconfirmed", wallet.Unconfirmed)
// 合約劃轉
v1.POST("contract/transfer", wallet.ContractTransfer)
// 系統劃轉
v1.POST("system-transfer", wallet.systemTransfer)
// 餘額
v1.GET("balance/:uid/user", wallet.Balance)
// 歷史餘額
v1.GET("balance/history/:uid/user", wallet.HistoryBalance)
// 資產
v1.GET("assets/balance/:uid/user", wallet.BalanceByAssets)
// 檢查餘額
v1.POST("balance", wallet.CheckBalance)
// 取得今日已提現金額
v1.GET("withdraw/today/user/:uid", wallet.GetTodayWithdraw)
// 合約平台餘額
v1.GET("balance/contract/system", wallet.ContractSystemBalance)