From fc8d58024ee9b7e9a70ef2169f7e86945c2c4d6d Mon Sep 17 00:00:00 2001 From: alphago9 Date: Sun, 29 Mar 2026 17:26:36 +0800 Subject: [PATCH 1/3] refactor: replace redis runtime and queues with sqlite-backed scheduler --- src/.env.example | 53 ++-- src/bootstrap/bootstrap.go | 15 +- src/config/config.go | 55 ++++- src/go.mod | 7 +- src/go.sum | 25 -- src/internal/testutil/testdb.go | 91 +++++++ src/model/dao/init.go | 4 +- src/model/dao/rdb.go | 46 ---- src/model/dao/runtime_sqlite.go | 66 +++++ src/model/data/order_data.go | 161 ++++++++---- src/model/mdb/orders_mdb.go | 23 +- src/model/mdb/transaction_lock_mdb.go | 18 ++ src/model/mdb/wallet_address_mdb.go | 6 +- src/model/service/order_service.go | 108 ++++---- src/model/service/order_service_test.go | 313 ++++++++++++++++++++++++ src/model/service/task_service.go | 55 ++--- src/mq/handle/callback_queue.go | 70 ------ src/mq/handle/order_expiration_queue.go | 39 --- src/mq/queue.go | 56 +---- src/mq/worker.go | 213 ++++++++++++++++ src/mq/worker_test.go | 191 +++++++++++++++ src/util/constant/errno.go | 24 +- 22 files changed, 1193 insertions(+), 446 deletions(-) create mode 100644 src/internal/testutil/testdb.go delete mode 100644 src/model/dao/rdb.go create mode 100644 src/model/dao/runtime_sqlite.go create mode 100644 src/model/mdb/transaction_lock_mdb.go create mode 100644 src/model/service/order_service_test.go delete mode 100644 src/mq/handle/callback_queue.go delete mode 100644 src/mq/handle/order_expiration_queue.go create mode 100644 src/mq/worker.go create mode 100644 src/mq/worker_test.go diff --git a/src/.env.example b/src/.env.example index 2748862..9c3b098 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,80 +1,59 @@ app_name=epusdt -#下面配置你的域名,收银台会需要 app_uri=https://dujiaoka.com -#是否开启debug,默认false app_debug=false -#http服务监听端口 http_listen=:8000 -#静态资源文件目录 static_path=/static -#缓存路径 runtime_root_path=/runtime -#日志配置 log_save_path=/logs log_max_size=32 log_max_age=7 max_backups=3 -#数据库类型,默认为mysql类型,可配置项: postgres,mysql,sqlite +# supported values: postgres,mysql,sqlite db_type=sqlite -# sqlite配置 +# sqlite primary database config sqlite_database_filename= sqlite_table_prefix= -# postgres配置 +# postgres config postgres_host=127.0.0.1 postgres_port=3306 -postgres_user=mysql账号 -postgres_passwd=mysql密码 -postgres_database=数据库 +postgres_user=mysql_user +postgres_passwd=mysql_password +postgres_database=database_name postgres_table_prefix= postgres_max_idle_conns=10 postgres_max_open_conns=100 postgres_max_life_time=6 -# mysql配置 +# mysql config mysql_host=127.0.0.1 mysql_port=3306 -mysql_user=mysql账号 -mysql_passwd=mysql密码 -mysql_database=数据库 +mysql_user=mysql_user +mysql_passwd=mysql_password +mysql_database=database_name mysql_table_prefix= mysql_max_idle_conns=10 mysql_max_open_conns=100 mysql_max_life_time=6 +# sqlite runtime store config +runtime_sqlite_filename=epusdt-runtime.db -# redis配置 -redis_host=127.0.0.1 -redis_port=6379 -redis_passwd= -redis_db=5 -redis_pool_size=5 -redis_max_retries=3 -redis_idle_timeout=1000 - -# 消息队列配置 +# background scheduler config queue_concurrency=10 -queue_level_critical=6 -queue_level_default=3 -queue_level_low=1 +queue_poll_interval_ms=1000 +callback_retry_base_seconds=5 -#机器人Apitoken tg_bot_token= -#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写 tg_proxy= -#管理员userid tg_manage= -#api接口认证token api_auth_token= -#订单过期时间(单位分钟) order_expiration_time=10 -#订单回调失败最大重试次数 order_notice_max_retry=0 -#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4) -forced_usdt_rate= \ No newline at end of file +forced_usdt_rate= diff --git a/src/bootstrap/bootstrap.go b/src/bootstrap/bootstrap.go index 2c8fae0..a615e9e 100644 --- a/src/bootstrap/bootstrap.go +++ b/src/bootstrap/bootstrap.go @@ -10,26 +10,15 @@ import ( "github.com/assimon/luuu/util/log" ) -// Start 服务启动 func Start() { - // 配置加载 config.Init() - // 日志加载 log.Init() - // // Mysql启动 - // dao.MysqlInit() - // dao.MdbTableInit() - // // redis启动 - // dao.RedisInit() dao.Init() - // 队列启动 mq.Start() - // telegram机器人启动 go telegram.BotStart() - // 定时任务 go task.Start() - err := command.Execute() - if err != nil { + + if err := command.Execute(); err != nil { panic(err) } } diff --git a/src/config/config.go b/src/config/config.go index 0708260..f9cadb4 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -2,10 +2,12 @@ package config import ( "fmt" - "github.com/spf13/viper" "net/url" "os" + "path/filepath" "time" + + "github.com/spf13/viper" ) var ( @@ -41,6 +43,8 @@ func Init() { "%s%s", RuntimePath, viper.GetString("log_save_path")) + mustMkdir(RuntimePath) + mustMkdir(LogSavePath) MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", url.QueryEscape(viper.GetString("mysql_user")), url.QueryEscape(viper.GetString("mysql_passwd")), @@ -54,6 +58,12 @@ func Init() { TgManage = viper.GetInt64("tg_manage") } +func mustMkdir(path string) { + if err := os.MkdirAll(path, 0o755); err != nil { + panic(err) + } +} + func GetAppVersion() string { return "0.0.2" } @@ -97,3 +107,46 @@ func GetOrderExpirationTimeDuration() time.Duration { timer := GetOrderExpirationTime() return time.Minute * time.Duration(timer) } + +func GetRuntimeSqlitePath() string { + filename := viper.GetString("runtime_sqlite_filename") + if filename == "" { + filename = "epusdt-runtime.db" + } + if filepath.IsAbs(filename) { + return filename + } + return filepath.Join(RuntimePath, filename) +} + +func GetQueueConcurrency() int { + concurrency := viper.GetInt("queue_concurrency") + if concurrency <= 0 { + return 10 + } + return concurrency +} + +func GetQueuePollInterval() time.Duration { + interval := viper.GetInt("queue_poll_interval_ms") + if interval <= 0 { + interval = 1000 + } + return time.Duration(interval) * time.Millisecond +} + +func GetOrderNoticeMaxRetry() int { + retry := viper.GetInt("order_notice_max_retry") + if retry < 0 { + return 0 + } + return retry +} + +func GetCallbackRetryBaseDuration() time.Duration { + seconds := viper.GetInt("callback_retry_base_seconds") + if seconds <= 0 { + seconds = 5 + } + return time.Duration(seconds) * time.Second +} diff --git a/src/go.mod b/src/go.mod index 0bf4fb8..08ea7df 100644 --- a/src/go.mod +++ b/src/go.mod @@ -5,12 +5,10 @@ go 1.25.0 require ( github.com/btcsuite/btcutil v1.0.2 github.com/dromara/carbon/v2 v2.6.15 - github.com/go-redis/redis/v8 v8.11.5 github.com/go-resty/resty/v2 v2.11.0 github.com/gookit/color v1.5.0 github.com/gookit/goutil v0.4.6 github.com/gookit/validate v1.3.1 - github.com/hibiken/asynq v0.25.1 github.com/json-iterator/go v1.1.12 github.com/labstack/echo/v4 v4.6.0 github.com/libtnb/sqlite v1.0.1 @@ -29,12 +27,11 @@ require ( ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/filter v1.1.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -55,7 +52,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml v1.9.4 // indirect - github.com/redis/go-redis/v9 v9.17.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -72,7 +68,6 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.63.2 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/src/go.sum b/src/go.sum index 7349ac0..472a06c 100644 --- a/src/go.sum +++ b/src/go.sum @@ -55,10 +55,6 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= @@ -71,8 +67,6 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -88,8 +82,6 @@ github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2 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/dromara/carbon/v2 v2.6.15 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI= github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -120,8 +112,6 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= @@ -251,8 +241,6 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= -github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -343,15 +331,9 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4 github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= @@ -364,8 +346,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= -github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -439,8 +419,6 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= @@ -861,8 +839,6 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -877,7 +853,6 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXL gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo= gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go new file mode 100644 index 0000000..591c9be --- /dev/null +++ b/src/internal/testutil/testdb.go @@ -0,0 +1,91 @@ +package testutil + +import ( + "path/filepath" + "testing" + + "github.com/assimon/luuu/config" + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/mdb" + "github.com/libtnb/sqlite" + "github.com/spf13/viper" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +func SetupTestDatabases(t testing.TB) func() { + t.Helper() + + viper.Reset() + viper.Set("forced_usdt_rate", 1.0) + viper.Set("app_uri", "https://example.com") + viper.Set("order_expiration_time", 10) + viper.Set("order_notice_max_retry", 2) + viper.Set("callback_retry_base_seconds", 1) + viper.Set("queue_concurrency", 4) + viper.Set("queue_poll_interval_ms", 50) + viper.Set("api_auth_token", "test-token") + + config.AppDebug = false + config.UsdtRate = 0 + + mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db")) + runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db")) + + mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{}) + mustMigrate(t, runtimeDB, &mdb.TransactionLock{}) + + dao.Mdb = mainDB + dao.RuntimeDB = runtimeDB + + return func() { + closeDB(t, runtimeDB) + closeDB(t, mainDB) + dao.Mdb = nil + dao.RuntimeDB = nil + viper.Reset() + } +} + +func mustOpenSQLite(t testing.TB, path string) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{SingularTable: true}, + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite %s: %v", path, err) + } + + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db handle for %s: %v", path, err) + } + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + return db +} + +func mustMigrate(t testing.TB, db *gorm.DB, models ...interface{}) { + t.Helper() + if err := db.AutoMigrate(models...); err != nil { + t.Fatalf("automigrate: %v", err) + } +} + +func closeDB(t testing.TB, db *gorm.DB) { + t.Helper() + if db == nil { + return + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db handle close: %v", err) + } + if err = sqlDB.Close(); err != nil { + t.Fatalf("close db: %v", err) + } +} diff --git a/src/model/dao/init.go b/src/model/dao/init.go index 920b6cb..d016736 100644 --- a/src/model/dao/init.go +++ b/src/model/dao/init.go @@ -9,7 +9,7 @@ func Init() { log.Fatalf("DBInit err: %v", err) } - if err := RedisInit(); err != nil { - log.Fatalf("RedisInit err: %v", err) + if err := RuntimeInit(); err != nil { + log.Fatalf("RuntimeInit err: %v", err) } } diff --git a/src/model/dao/rdb.go b/src/model/dao/rdb.go deleted file mode 100644 index 0bda4c5..0000000 --- a/src/model/dao/rdb.go +++ /dev/null @@ -1,46 +0,0 @@ -package dao - -import ( - "context" - "fmt" - "time" - - "github.com/assimon/luuu/util/log" - "github.com/go-redis/redis/v8" - "github.com/gookit/color" - "github.com/spf13/viper" -) - -var Rdb *redis.Client - -func RedisInit() error { - options := redis.Options{ - Addr: fmt.Sprintf( - "%s:%s", - viper.GetString("redis_host"), - viper.GetString("redis_port")), // Redis地址 - DB: viper.GetInt("redis_db"), // Redis库 - PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小 - MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数 - IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间 - } - if viper.GetString("redis_passwd") != "" { - options.Password = viper.GetString("redis_passwd") - } - Rdb = redis.NewClient(&options) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - pong, err := Rdb.Ping(ctx).Result() - if err == redis.Nil { - log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.") - } else if err != nil { - color.Red.Printf("[store_redis] redis connRdb err,err=%s", err) - // panic(err) - // time.Sleep(10 * time.Second) - // RedisInit() - return err - } else { - log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong) - } - return nil -} diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go new file mode 100644 index 0000000..2a8aa58 --- /dev/null +++ b/src/model/dao/runtime_sqlite.go @@ -0,0 +1,66 @@ +package dao + +import ( + "github.com/assimon/luuu/config" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/log" + "github.com/gookit/color" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +var RuntimeDB *gorm.DB + +func RuntimeInit() error { + var err error + runtimePath := config.GetRuntimeSqlitePath() + color.Green.Printf("[runtime_db] sqlite filename: %s\n", runtimePath) + RuntimeDB, err = openDB(runtimePath, &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + SingularTable: true, + }, + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + color.Red.Printf("[runtime_db] sqlite open DB,err=%s\n", err) + return err + } + + sqlDB, err := RuntimeDB.DB() + if err != nil { + color.Red.Printf("[runtime_db] sqlite get DB,err=%s\n", err) + return err + } + + concurrency := config.GetQueueConcurrency() + if concurrency < 2 { + concurrency = 2 + } + if concurrency > 16 { + concurrency = 16 + } + sqlDB.SetMaxOpenConns(concurrency) + sqlDB.SetMaxIdleConns(1) + + if err = RuntimeDB.Exec("PRAGMA journal_mode=WAL;").Error; err != nil { + return err + } + if err = RuntimeDB.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil { + return err + } + if err = RuntimeDB.Exec("PRAGMA busy_timeout=5000;").Error; err != nil { + return err + } + if err = sqlDB.Ping(); err != nil { + color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error()) + return err + } + if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil { + color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err) + return err + } + + log.Sugar.Debug("[runtime_db] sqlite connDB success") + return nil +} diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index d66c5b1..1de5612 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -1,109 +1,162 @@ package data import ( - "context" - "fmt" + "errors" + "time" + "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" - "github.com/go-redis/redis/v8" + "github.com/shopspring/decimal" "gorm.io/gorm" - "time" + "gorm.io/gorm/clause" ) -var ( - CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号 -) +var ErrTransactionLocked = errors.New("transaction amount is already locked") -// GetOrderInfoByOrderId 通过客户订单号查询订单 +func normalizeLockAmount(amount float64) (int64, string) { + value := decimal.NewFromFloat(amount).Round(2) + return value.Shift(2).IntPart(), value.StringFixed(2) +} + +// GetOrderInfoByOrderId fetches an order by merchant order id. func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { order := new(mdb.Orders) err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error return order, err } -// GetOrderInfoByTradeId 通过交易号查询订单 +// GetOrderInfoByTradeId fetches an order by epusdt trade id. func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { order := new(mdb.Orders) err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error return order, err } -// CreateOrderWithTransaction 事务创建订单 +// CreateOrderWithTransaction creates an order in the active database transaction. func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { - err := tx.Model(order).Create(order).Error - return err + return tx.Model(order).Create(order).Error } -// GetOrderByBlockIdWithTransaction 通过区块获取订单 +// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id. func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { order := new(mdb.Orders) err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error return order, err } -// OrderSuccessWithTransaction 事务支付成功 -func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error { - err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{ - "block_transaction_id": req.BlockTransactionId, - "status": mdb.StatusPaySuccess, - "callback_confirm": mdb.CallBackConfirmNo, - }).Error - return err +// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment. +func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) { + result := tx.Model(&mdb.Orders{}). + Where("trade_id = ?", req.TradeId). + Where("status = ?", mdb.StatusWaitPay). + Updates(map[string]interface{}{ + "block_transaction_id": req.BlockTransactionId, + "status": mdb.StatusPaySuccess, + "callback_confirm": mdb.CallBackConfirmNo, + }) + return result.RowsAffected > 0, result.Error } -// GetPendingCallbackOrders 查询出等待回调的订单 -func GetPendingCallbackOrders() ([]mdb.Orders, error) { +// GetPendingCallbackOrders returns orders that still need callback delivery. +func GetPendingCallbackOrders(maxRetry int, limit int) ([]mdb.Orders, error) { var orders []mdb.Orders - err := dao.Mdb.Model(orders). - Where("callback_num < ?", 5). + query := dao.Mdb.Model(&mdb.Orders{}). + Where("callback_num <= ?", maxRetry). Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("status = ?", mdb.StatusPaySuccess). - Find(&orders).Error + Order("updated_at asc") + if limit > 0 { + query = query.Limit(limit) + } + err := query.Find(&orders).Error return orders, err } -// SaveCallBackOrdersResp 保存订单回调结果 +// SaveCallBackOrdersResp persists a callback attempt result. func SaveCallBackOrdersResp(order *mdb.Orders) error { - err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{ - "callback_num": gorm.Expr("callback_num + ?", 1), - "callback_confirm": order.CallBackConfirm, - }).Error - return err + return dao.Mdb.Model(order). + Where("id = ?", order.ID). + Where("callback_confirm = ?", mdb.CallBackConfirmNo). + Updates(map[string]interface{}{ + "callback_num": gorm.Expr("callback_num + ?", 1), + "callback_confirm": order.CallBackConfirm, + }).Error } -// UpdateOrderIsExpirationById 通过id设置订单过期 -func UpdateOrderIsExpirationById(id uint64) error { - err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error - return err +// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out. +func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) { + result := dao.Mdb.Model(mdb.Orders{}). + Where("id = ?", id). + Where("status = ?", mdb.StatusWaitPay). + Where("created_at <= ?", expirationCutoff). + Update("status", mdb.StatusExpired) + return result.RowsAffected > 0, result.Error } -// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号 +// GetTradeIdByWalletAddressAndAmount resolves the reserved trade id by token and amount. func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - result, err := dao.Rdb.Get(ctx, cacheKey).Result() - if err == redis.Nil { - return "", nil - } + scaledAmount, _ := normalizeLockAmount(amount) + var lock mdb.TransactionLock + err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). + Where("token = ?", token). + Where("amount_scaled = ?", scaledAmount). + Where("expires_at > ?", time.Now()). + Limit(1). + Find(&lock).Error if err != nil { return "", err } - return result, nil + if lock.ID <= 0 { + return "", nil + } + return lock.TradeId, nil } -// LockTransaction 锁定交易 +// LockTransaction reserves a token+amount pair in sqlite until expiration. func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err() - return err + scaledAmount, amountText := normalizeLockAmount(amount) + now := time.Now() + lock := &mdb.TransactionLock{ + Token: token, + AmountScaled: scaledAmount, + AmountText: amountText, + TradeId: tradeId, + ExpiresAt: now.Add(expirationTime), + } + + return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("token = ?", token). + Where("amount_scaled = ?", scaledAmount). + Where("expires_at <= ?", now). + Delete(&mdb.TransactionLock{}).Error; err != nil { + return err + } + if err := tx.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error; err != nil { + return err + } + + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrTransactionLocked + } + return nil + }) } -// UnLockTransaction 解锁交易 +// UnLockTransaction releases the reservation for token+amount. func UnLockTransaction(token string, amount float64) error { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - err := dao.Rdb.Del(ctx, cacheKey).Err() - return err + scaledAmount, _ := normalizeLockAmount(amount) + return dao.RuntimeDB.Where("token = ?", token).Where("amount_scaled = ?", scaledAmount).Delete(&mdb.TransactionLock{}).Error +} + +func UnLockTransactionByTradeId(tradeId string) error { + return dao.RuntimeDB.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error +} + +func CleanupExpiredTransactionLocks() error { + return dao.RuntimeDB.Where("expires_at <= ?", time.Now()).Delete(&mdb.TransactionLock{}).Error } diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 6b54047..0083d43 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -9,21 +9,20 @@ const ( ) type Orders struct { - TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号 - OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id - BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` // 区块id - Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数 - ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `gorm:"column:token" json:"token"` // 所属钱包地址 - Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期 - NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 - RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 - CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数 - CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否 + TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` + OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` + BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` + Amount float64 `gorm:"column:amount" json:"amount"` + ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` + Token string `gorm:"column:token" json:"token"` + Status int `gorm:"column:status;default:1" json:"status"` + NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` + RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` + CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` + CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` BaseModel } -// TableName sets the insert table name for this struct type func (o *Orders) TableName() string { return "orders" } diff --git a/src/model/mdb/transaction_lock_mdb.go b/src/model/mdb/transaction_lock_mdb.go new file mode 100644 index 0000000..26610ae --- /dev/null +++ b/src/model/mdb/transaction_lock_mdb.go @@ -0,0 +1,18 @@ +package mdb + +import "time" + +type TransactionLock struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Token string `gorm:"column:token;uniqueIndex:transaction_lock_token_amount_uindex,priority:1" json:"token"` + AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_token_amount_uindex,priority:2" json:"amount_scaled"` + AmountText string `gorm:"column:amount_text" json:"amount_text"` + TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"` + ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (t *TransactionLock) TableName() string { + return "transaction_lock" +} diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index d12d767..a723f99 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -5,14 +5,12 @@ const ( TokenStatusDisable = 2 ) -// WalletAddress 钱包表 type WalletAddress struct { - Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token - Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用 + Token string `gorm:"column:token;uniqueIndex:wallet_address_token_uindex" json:"token"` + Status int64 `gorm:"column:status;default:1" json:"status"` BaseModel } -// TableName sets the insert table name for this struct type func (w *WalletAddress) TableName() string { return "wallet_address" } diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 3173924..ca25bbf 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -1,6 +1,7 @@ package service import ( + "errors" "fmt" "math/rand" "sync" @@ -12,42 +13,39 @@ import ( "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/response" - "github.com/assimon/luuu/mq" - "github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/util/constant" + "github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/math" "github.com/dromara/carbon/v2" - "github.com/hibiken/asynq" "github.com/shopspring/decimal" ) const ( - CnyMinimumPaymentAmount = 0.01 // cny最低支付金额 - UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额 - UsdtAmountPerIncrement = 0.01 // usdt每次递增金额 - IncrementalMaximumNumber = 100 // 最大递增次数 + CnyMinimumPaymentAmount = 0.01 + UsdtMinimumPaymentAmount = 0.01 + UsdtAmountPerIncrement = 0.01 + IncrementalMaximumNumber = 100 ) var gCreateTransactionLock sync.Mutex +var gOrderProcessingLock sync.Mutex -// CreateTransaction 创建订单 +// CreateTransaction creates a new payment order. func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { gCreateTransactionLock.Lock() defer gCreateTransactionLock.Unlock() + payAmount := math.MustParsePrecFloat64(req.Amount, 2) - // 按照汇率转化USDT decimalPayAmount := decimal.NewFromFloat(payAmount) decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) decimalUsdt := decimalPayAmount.Div(decimalRate) - // cny 是否可以满足最低支付金额 if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } - // Usdt是否可以满足最低支付金额 if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } - // 已经存在了的交易 + exist, err := data.GetOrderInfoByOrderId(req.OrderId) if err != nil { return nil, err @@ -55,7 +53,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT if exist.ID > 0 { return nil, constant.OrderAlreadyExists } - // 有无可用钱包 + walletAddress, err := data.GetAvailableWalletAddress() if err != nil { return nil, err @@ -63,17 +61,20 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT if len(walletAddress) <= 0 { return nil, constant.NotAvailableWalletAddress } + + tradeId := GenerateCode() amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) - availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress) + availableToken, availableAmount, err := ReserveAvailableWalletAndAmount(tradeId, amount, walletAddress) if err != nil { return nil, err } if availableToken == "" { return nil, constant.NotAvailableAmountErr } + tx := dao.Mdb.Begin() order := &mdb.Orders{ - TradeId: GenerateCode(), + TradeId: tradeId, OrderId: req.OrderId, Amount: req.Amount, ActualAmount: availableAmount, @@ -82,88 +83,90 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT NotifyUrl: req.NotifyUrl, RedirectUrl: req.RedirectUrl, } - err = data.CreateOrderWithTransaction(tx, order) - if err != nil { + if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() + _ = data.UnLockTransaction(availableToken, availableAmount) return nil, err } - // 锁定支付池 - err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration()) - if err != nil { + if err = tx.Commit().Error; err != nil { tx.Rollback() + _ = data.UnLockTransaction(availableToken, availableAmount) return nil, err } - tx.Commit() - // 超时过期消息队列 - orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId) - mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()), - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ) - ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp() + + expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp() resp := &response.CreateTransactionResponse{ TradeId: order.TradeId, OrderId: order.OrderId, Amount: order.Amount, ActualAmount: order.ActualAmount, Token: order.Token, - ExpirationTime: ExpirationTime, + ExpirationTime: expirationTime, PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), } return resp, nil } -// OrderProcessing 成功处理订单 +// OrderProcessing marks an order as paid and releases its sqlite reservation. func OrderProcessing(req *request.OrderProcessingRequest) error { + gOrderProcessingLock.Lock() + defer gOrderProcessingLock.Unlock() + tx := dao.Mdb.Begin() exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId) if err != nil { + tx.Rollback() return err } if exist.ID > 0 { tx.Rollback() return constant.OrderBlockAlreadyProcess } - // 标记订单成功 - err = data.OrderSuccessWithTransaction(tx, req) + + updated, err := data.OrderSuccessWithTransaction(tx, req) if err != nil { tx.Rollback() return err } - // 解锁交易 - err = data.UnLockTransaction(req.Token, req.Amount) - if err != nil { + if !updated { + tx.Rollback() + return constant.OrderStatusConflict + } + if err = tx.Commit().Error; err != nil { tx.Rollback() return err } - tx.Commit() + + if err = data.UnLockTransaction(req.Token, req.Amount); err != nil { + log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) + } return nil } -// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 -func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { +// ReserveAvailableWalletAndAmount finds and locks a token+amount pair. +func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { availableToken := "" availableAmount := amount - calculateAvailableWalletFunc := func(amount float64) (string, error) { - availableWallet := "" + + tryLockWalletFunc := func(targetAmount float64) (string, error) { for _, address := range walletAddress { - token := address.Token - result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) - if err != nil { - return "", err + err := data.LockTransaction(address.Token, tradeId, targetAmount, config.GetOrderExpirationTimeDuration()) + if err == nil { + return address.Token, nil } - if result == "" { - availableWallet = token - break + if errors.Is(err, data.ErrTransactionLocked) { + continue } + return "", err } - return availableWallet, nil + return "", nil } + for i := 0; i < IncrementalMaximumNumber; i++ { - token, err := calculateAvailableWalletFunc(availableAmount) + token, err := tryLockWalletFunc(availableAmount) if err != nil { return "", 0, err } - // 拿不到可用钱包就累加金额 if token == "" { decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) @@ -176,15 +179,14 @@ func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.Walle return availableToken, availableAmount, nil } -// GenerateCode 订单号生成 +// GenerateCode creates a unique trade id. func GenerateCode() string { date := time.Now().Format("20060102") r := rand.Intn(1000) - code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) - return code + return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) } -// GetOrderInfoByTradeId 通过交易号获取订单 +// GetOrderInfoByTradeId returns a validated order. func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { order, err := data.GetOrderInfoByTradeId(tradeId) if err != nil { diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go new file mode 100644 index 0000000..8c354d1 --- /dev/null +++ b/src/model/service/order_service_test.go @@ -0,0 +1,313 @@ +package service + +import ( + "fmt" + "sync" + "testing" + + "github.com/assimon/luuu/internal/testutil" + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/request" + "github.com/assimon/luuu/util/constant" +) + +func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("wallet_1"); err != nil { + t.Fatalf("add wallet: %v", err) + } + + req1 := &request.CreateTransactionRequest{ + OrderId: "order_1", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + } + req2 := &request.CreateTransactionRequest{ + OrderId: "order_2", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + } + + resp1, err := CreateTransaction(req1) + if err != nil { + t.Fatalf("create first transaction: %v", err) + } + resp2, err := CreateTransaction(req2) + if err != nil { + t.Fatalf("create second transaction: %v", err) + } + + if got := fmt.Sprintf("%.2f", resp1.ActualAmount); got != "1.00" { + t.Fatalf("first actual amount = %s, want 1.00", got) + } + if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" { + t.Fatalf("second actual amount = %s, want 1.01", got) + } + if resp1.Token != "wallet_1" || resp2.Token != "wallet_1" { + t.Fatalf("unexpected wallet tokens: %s, %s", resp1.Token, resp2.Token) + } + + tradeID1, err := data.GetTradeIdByWalletAddressAndAmount(resp1.Token, resp1.ActualAmount) + if err != nil { + t.Fatalf("get first runtime lock: %v", err) + } + if tradeID1 != resp1.TradeId { + t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId) + } + + tradeID2, err := data.GetTradeIdByWalletAddressAndAmount(resp2.Token, resp2.ActualAmount) + if err != nil { + t.Fatalf("get second runtime lock: %v", err) + } + if tradeID2 != resp2.TradeId { + t.Fatalf("second runtime lock = %s, want %s", tradeID2, resp2.TradeId) + } +} + +func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("wallet_1"); err != nil { + t.Fatalf("add wallet: %v", err) + } + + resp, err := CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_1", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + }) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + + err = OrderProcessing(&request.OrderProcessingRequest{ + Token: resp.Token, + TradeId: resp.TradeId, + Amount: resp.ActualAmount, + BlockTransactionId: "block_1", + }) + if err != nil { + t.Fatalf("order processing: %v", err) + } + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("get order by trade id: %v", err) + } + if order.Status != mdb.StatusPaySuccess { + t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess) + } + if order.CallBackConfirm != mdb.CallBackConfirmNo { + t.Fatalf("callback confirm = %d, want %d", order.CallBackConfirm, mdb.CallBackConfirmNo) + } + if order.BlockTransactionId != "block_1" { + t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId) + } + + tradeID, err := data.GetTradeIdByWalletAddressAndAmount(resp.Token, resp.ActualAmount) + if err != nil { + t.Fatalf("get runtime lock after processing: %v", err) + } + if tradeID != "" { + t.Fatalf("runtime lock still exists: %s", tradeID) + } +} + +func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("wallet_1"); err != nil { + t.Fatalf("add wallet: %v", err) + } + + resp, err := CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_1", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + }) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + + req := &request.OrderProcessingRequest{ + Token: resp.Token, + TradeId: resp.TradeId, + Amount: resp.ActualAmount, + BlockTransactionId: "block_1", + } + if err = OrderProcessing(req); err != nil { + t.Fatalf("first order processing: %v", err) + } + + err = OrderProcessing(req) + if err != constant.OrderBlockAlreadyProcess { + t.Fatalf("second order processing error = %v, want %v", err, constant.OrderBlockAlreadyProcess) + } + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload order after duplicate block: %v", err) + } + if order.Status != mdb.StatusPaySuccess { + t.Fatalf("order status after duplicate block = %d, want %d", order.Status, mdb.StatusPaySuccess) + } + if order.BlockTransactionId != "block_1" { + t.Fatalf("order block transaction id after duplicate block = %s, want block_1", order.BlockTransactionId) + } +} + +func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("wallet_1"); err != nil { + t.Fatalf("add wallet: %v", err) + } + + resp, err := CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_1", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + }) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + + if err = dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", resp.TradeId). + Update("status", mdb.StatusExpired).Error; err != nil { + t.Fatalf("force order expired: %v", err) + } + + err = OrderProcessing(&request.OrderProcessingRequest{ + Token: resp.Token, + TradeId: resp.TradeId, + Amount: resp.ActualAmount, + BlockTransactionId: "block_expired", + }) + if err != constant.OrderStatusConflict { + t.Fatalf("order processing error = %v, want %v", err, constant.OrderStatusConflict) + } + + order, err := data.GetOrderInfoByTradeId(resp.TradeId) + if err != nil { + t.Fatalf("reload expired order: %v", err) + } + if order.Status != mdb.StatusExpired { + t.Fatalf("expired order status = %d, want %d", order.Status, mdb.StatusExpired) + } + if order.BlockTransactionId != "" { + t.Fatalf("expired order block transaction id = %s, want empty", order.BlockTransactionId) + } +} + +func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("wallet_1"); err != nil { + t.Fatalf("add wallet: %v", err) + } + if _, err := data.AddWalletAddress("wallet_2"); err != nil { + t.Fatalf("add wallet: %v", err) + } + + resp1, err := CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_1", + Amount: 1, + NotifyUrl: "https://merchant.example/callback", + }) + if err != nil { + t.Fatalf("create first transaction: %v", err) + } + resp2, err := CreateTransaction(&request.CreateTransactionRequest{ + OrderId: "order_2", + Amount: 2, + NotifyUrl: "https://merchant.example/callback", + }) + if err != nil { + t.Fatalf("create second transaction: %v", err) + } + + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, tc := range []struct { + token string + tradeID string + amount float64 + }{ + {token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount}, + {token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount}, + } { + wg.Add(1) + go func(token, tradeID string, amount float64) { + defer wg.Done() + <-start + errs <- OrderProcessing(&request.OrderProcessingRequest{ + Token: token, + TradeId: tradeID, + Amount: amount, + BlockTransactionId: "shared_block", + }) + }(tc.token, tc.tradeID, tc.amount) + } + + close(start) + wg.Wait() + close(errs) + + var successCount int + var duplicateCount int + for err := range errs { + switch err { + case nil: + successCount++ + case constant.OrderBlockAlreadyProcess: + duplicateCount++ + default: + t.Fatalf("unexpected order processing error: %v", err) + } + } + if successCount != 1 || duplicateCount != 1 { + t.Fatalf("success=%d duplicate=%d, want 1 and 1", successCount, duplicateCount) + } + + orders := []struct { + tradeID string + }{ + {tradeID: resp1.TradeId}, + {tradeID: resp2.TradeId}, + } + var paidCount int + var pendingCount int + for _, item := range orders { + order, err := data.GetOrderInfoByTradeId(item.tradeID) + if err != nil { + t.Fatalf("reload order %s: %v", item.tradeID, err) + } + switch order.Status { + case mdb.StatusPaySuccess: + paidCount++ + if order.BlockTransactionId != "shared_block" { + t.Fatalf("paid order block transaction id = %s, want shared_block", order.BlockTransactionId) + } + case mdb.StatusWaitPay: + pendingCount++ + if order.BlockTransactionId != "" { + t.Fatalf("pending order block transaction id = %s, want empty", order.BlockTransactionId) + } + default: + t.Fatalf("unexpected order status for %s: %d", item.tradeID, order.Status) + } + } + if paidCount != 1 || pendingCount != 1 { + t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount) + } +} diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index f5c8b17..218ab82 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -1,24 +1,20 @@ package service import ( + "errors" "fmt" "net/http" "sync" - "github.com/assimon/luuu/config" - "github.com/spf13/viper" - "github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/request" - "github.com/assimon/luuu/mq" - "github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/telegram" + "github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/http_client" "github.com/assimon/luuu/util/json" "github.com/assimon/luuu/util/log" "github.com/dromara/carbon/v2" "github.com/gookit/goutil/stdutil" - "github.com/hibiken/asynq" "github.com/shopspring/decimal" ) @@ -64,7 +60,7 @@ type Data struct { Direction int `json:"direction"` } -// Trc20CallBack trc20回调 +// Trc20CallBack polls transfers for one wallet and matches them to active orders. func Trc20CallBack(token string, wg *sync.WaitGroup) { defer wg.Done() defer func() { @@ -72,6 +68,7 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) { log.Sugar.Error(err) } }() + client := http_client.GetHttpClient() startTime := carbon.Now().AddHours(-24).TimestampMilli() endTime := carbon.Now().TimestampMilli() @@ -92,18 +89,20 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) { if resp.StatusCode() != http.StatusOK { panic(err) } + var trc20Resp UsdtTrc20Resp - err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp) - if err != nil { + if err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp); err != nil { panic(err) } if trc20Resp.PageSize <= 0 { return } + for _, transfer := range trc20Resp.Data { if transfer.To != token || transfer.ContractRet != "SUCCESS" { continue } + decimalQuant, err := decimal.NewFromString(transfer.Amount) if err != nil { panic(err) @@ -117,43 +116,39 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) { if tradeId == "" { continue } + order, err := data.GetOrderInfoByTradeId(tradeId) if err != nil { panic(err) } - // 区块的确认时间必须在订单创建时间之后 createTime := order.CreatedAt.TimestampMilli() if transfer.BlockTimestamp < createTime { - panic("Orders cannot actually be matched") + panic("orders cannot actually be matched") } - // 到这一步就完全算是支付成功了 + req := &request.OrderProcessingRequest{ Token: token, TradeId: tradeId, Amount: amount, BlockTransactionId: transfer.Hash, } - err = OrderProcessing(req) - if err != nil { + if err = OrderProcessing(req); err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[task] skip already resolved transfer, trade_id=%s, block_transaction_id=%s, err=%v", tradeId, transfer.Hash, err) + continue + } panic(err) } - // 回调队列 - orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) - orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry") - mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry), - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ) - // mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5)) - // 发送机器人消息 + msgTpl := ` -📢📢有新的交易支付成功! -
交易号:%s
-
订单号:%s
-
请求支付金额:%f cny
-
实际支付金额:%f usdt
-
钱包地址:%s
-
订单创建时间:%s
-
支付成功时间:%s
+馃摙馃摙鏈夋柊鐨勪氦鏄撴敮浠樻垚鍔燂紒 +
浜ゆ槗鍙凤細%s
+
璁㈠崟鍙凤細%s
+
璇锋眰鏀粯閲戦锛?f cny
+
瀹為檯鏀粯閲戦锛?f usdt
+
閽卞寘鍦板潃锛?s
+
璁㈠崟鍒涘缓鏃堕棿锛?s
+
鏀粯鎴愬姛鏃堕棿锛?s
` msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString()) telegram.SendToBot(msg) diff --git a/src/mq/handle/callback_queue.go b/src/mq/handle/callback_queue.go deleted file mode 100644 index 230e815..0000000 --- a/src/mq/handle/callback_queue.go +++ /dev/null @@ -1,70 +0,0 @@ -package handle - -import ( - "context" - "errors" - - "github.com/assimon/luuu/config" - "github.com/assimon/luuu/model/data" - "github.com/assimon/luuu/model/mdb" - "github.com/assimon/luuu/model/response" - "github.com/assimon/luuu/util/http_client" - "github.com/assimon/luuu/util/json" - "github.com/assimon/luuu/util/log" - "github.com/assimon/luuu/util/sign" - "github.com/hibiken/asynq" -) - -const QueueOrderCallback = "order:callback" - -func NewOrderCallbackQueue(order *mdb.Orders) (*asynq.Task, error) { - payload, err := json.Cjson.Marshal(order) - if err != nil { - return nil, err - } - return asynq.NewTask(QueueOrderCallback, payload, - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ), nil -} - -func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error { - var order mdb.Orders - err := json.Cjson.Unmarshal(t.Payload(), &order) - if err != nil { - return err - } - defer func() { - if err := recover(); err != nil { - log.Sugar.Error(err) - } - }() - defer func() { - data.SaveCallBackOrdersResp(&order) - }() - client := http_client.GetHttpClient() - orderResp := response.OrderNotifyResponse{ - TradeId: order.TradeId, - OrderId: order.OrderId, - Amount: order.Amount, - ActualAmount: order.ActualAmount, - Token: order.Token, - BlockTransactionId: order.BlockTransactionId, - Status: mdb.StatusPaySuccess, - } - signature, err := sign.Get(orderResp, config.GetApiAuthToken()) - if err != nil { - return err - } - orderResp.Signature = signature - resp, err := client.R().SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)").SetBody(orderResp).Post(order.NotifyUrl) - if err != nil { - return err - } - body := string(resp.Body()) - if body != "ok" { - order.CallBackConfirm = mdb.CallBackConfirmNo - return errors.New("not ok") - } - order.CallBackConfirm = mdb.CallBackConfirmOk - return nil -} diff --git a/src/mq/handle/order_expiration_queue.go b/src/mq/handle/order_expiration_queue.go deleted file mode 100644 index 7d57887..0000000 --- a/src/mq/handle/order_expiration_queue.go +++ /dev/null @@ -1,39 +0,0 @@ -package handle - -import ( - "context" - - "github.com/assimon/luuu/config" - "github.com/assimon/luuu/model/data" - "github.com/assimon/luuu/model/mdb" - "github.com/hibiken/asynq" -) - -const QueueOrderExpiration = "order:expiration" - -func NewOrderExpirationQueue(tradeId string) (*asynq.Task, error) { - return asynq.NewTask(QueueOrderExpiration, []byte(tradeId), - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ), nil -} - -// OrderExpirationHandle 设置订单过期 -func OrderExpirationHandle(ctx context.Context, t *asynq.Task) error { - tradeId := string(t.Payload()) - orderInfo, err := data.GetOrderInfoByTradeId(tradeId) - if err != nil { - return err - } - if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { - return nil - } - err = data.UpdateOrderIsExpirationById(orderInfo.ID) - if err != nil { - return err - } - err = data.UnLockTransaction(orderInfo.Token, orderInfo.ActualAmount) - if err != nil { - return err - } - return nil -} diff --git a/src/mq/queue.go b/src/mq/queue.go index 68db830..8d350e5 100644 --- a/src/mq/queue.go +++ b/src/mq/queue.go @@ -1,52 +1,22 @@ package mq import ( - "fmt" + "sync" - "github.com/assimon/luuu/mq/handle" - "github.com/assimon/luuu/util/log" - "github.com/hibiken/asynq" - "github.com/spf13/viper" + "github.com/assimon/luuu/config" ) -var MClient *asynq.Client +var ( + startOnce sync.Once + callbackLimiter chan struct{} + callbackInflight sync.Map +) func Start() { - redis := asynq.RedisClientOpt{ - Addr: fmt.Sprintf( - "%s:%s", - viper.GetString("redis_host"), - viper.GetString("redis_port")), - DB: viper.GetInt("redis_db"), - Password: viper.GetString("redis_passwd"), - } - initClient(redis) - go initListen(redis) -} - -func initClient(redis asynq.RedisClientOpt) { - MClient = asynq.NewClient(redis) -} - -func initListen(redis asynq.RedisClientOpt) { - srv := asynq.NewServer( - redis, - asynq.Config{ - // Specify how many concurrent workers to use - Concurrency: viper.GetInt("queue_concurrency"), - // Optionally specify multiple queues with different priority. - Queues: map[string]int{ - "critical": viper.GetInt("queue_level_critical"), - "default": viper.GetInt("queue_level_default"), - "low": viper.GetInt("queue_level_low"), - }, - Logger: log.Sugar, - }, - ) - mux := asynq.NewServeMux() - mux.HandleFunc(handle.QueueOrderExpiration, handle.OrderExpirationHandle) - mux.HandleFunc(handle.QueueOrderCallback, handle.OrderCallbackHandle) - if err := srv.Run(mux); err != nil { - log.Sugar.Fatalf("[queue] could not run server: %v", err) - } + startOnce.Do(func() { + callbackLimiter = make(chan struct{}, config.GetQueueConcurrency()) + go runOrderExpirationLoop() + go runOrderCallbackLoop() + go runTransactionLockCleanupLoop() + }) } diff --git a/src/mq/worker.go b/src/mq/worker.go new file mode 100644 index 0000000..ff9fb04 --- /dev/null +++ b/src/mq/worker.go @@ -0,0 +1,213 @@ +package mq + +import ( + "errors" + "net/http" + "time" + + "github.com/assimon/luuu/config" + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/response" + "github.com/assimon/luuu/util/http_client" + "github.com/assimon/luuu/util/log" + "github.com/assimon/luuu/util/sign" +) + +const batchSize = 100 + +func runOrderExpirationLoop() { + runLoop("order_expiration", processExpiredOrders) +} + +func runOrderCallbackLoop() { + runLoop("order_callback", dispatchPendingCallbacks) +} + +func runTransactionLockCleanupLoop() { + runLoop("transaction_lock_cleanup", cleanupExpiredTransactionLocks) +} + +func runLoop(name string, fn func()) { + safeRun(name, fn) + ticker := time.NewTicker(config.GetQueuePollInterval()) + defer ticker.Stop() + + for range ticker.C { + safeRun(name, fn) + } +} + +func safeRun(name string, fn func()) { + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[mq] %s panic: %v", name, err) + } + }() + fn() +} + +func processExpiredOrders() { + expirationCutoff := time.Now().Add(-config.GetOrderExpirationTimeDuration()) + for { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("status = ?", mdb.StatusWaitPay). + Where("created_at <= ?", expirationCutoff). + Order("id asc"). + Limit(batchSize). + Find(&orders).Error + if err != nil { + log.Sugar.Errorf("[mq] query expired orders failed: %v", err) + return + } + if len(orders) == 0 { + return + } + + for _, order := range orders { + expired, err := data.UpdateOrderIsExpirationById(order.ID, expirationCutoff) + if err != nil { + log.Sugar.Errorf("[mq] expire order failed, trade_id=%s, err=%v", order.TradeId, err) + continue + } + if !expired { + continue + } + if err = data.UnLockTransaction(order.Token, order.ActualAmount); err != nil { + log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err) + } + } + + if len(orders) < batchSize { + return + } + } +} + +func dispatchPendingCallbacks() { + maxRetry := config.GetOrderNoticeMaxRetry() + orders, err := data.GetPendingCallbackOrders(maxRetry, batchSize) + if err != nil { + log.Sugar.Errorf("[mq] query callback orders failed: %v", err) + return + } + + now := time.Now() + for _, order := range orders { + if !isCallbackDue(&order, now, maxRetry) { + continue + } + if _, loaded := callbackInflight.LoadOrStore(order.TradeId, struct{}{}); loaded { + continue + } + + select { + case callbackLimiter <- struct{}{}: + go processCallback(order) + default: + callbackInflight.Delete(order.TradeId) + return + } + } +} + +func processCallback(order mdb.Orders) { + defer func() { + <-callbackLimiter + callbackInflight.Delete(order.TradeId) + }() + + freshOrder, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil { + log.Sugar.Errorf("[mq] reload callback order failed, trade_id=%s, err=%v", order.TradeId, err) + return + } + if freshOrder.ID <= 0 || freshOrder.Status != mdb.StatusPaySuccess || freshOrder.CallBackConfirm != mdb.CallBackConfirmNo { + return + } + + if err = sendOrderCallback(freshOrder); err != nil { + log.Sugar.Warnf("[mq] callback request failed, trade_id=%s, attempt=%d, err=%v", freshOrder.TradeId, freshOrder.CallbackNum+1, err) + freshOrder.CallBackConfirm = mdb.CallBackConfirmNo + } else { + freshOrder.CallBackConfirm = mdb.CallBackConfirmOk + } + + if err = data.SaveCallBackOrdersResp(freshOrder); err != nil { + log.Sugar.Errorf("[mq] save callback result failed, trade_id=%s, err=%v", freshOrder.TradeId, err) + } +} + +func sendOrderCallback(order *mdb.Orders) error { + client := http_client.GetHttpClient() + orderResp := response.OrderNotifyResponse{ + TradeId: order.TradeId, + OrderId: order.OrderId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + Token: order.Token, + BlockTransactionId: order.BlockTransactionId, + Status: mdb.StatusPaySuccess, + } + signature, err := sign.Get(orderResp, config.GetApiAuthToken()) + if err != nil { + return err + } + orderResp.Signature = signature + + resp, err := client.R(). + SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)"). + SetBody(orderResp). + Post(order.NotifyUrl) + if err != nil { + return err + } + if resp.StatusCode() != http.StatusOK { + return errors.New(resp.Status()) + } + if string(resp.Body()) != "ok" { + return errors.New("not ok") + } + return nil +} + +func cleanupExpiredTransactionLocks() { + if err := data.CleanupExpiredTransactionLocks(); err != nil { + log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err) + } +} + +func isCallbackDue(order *mdb.Orders, now time.Time, maxRetry int) bool { + if order.CallBackConfirm != mdb.CallBackConfirmNo { + return false + } + if order.CallbackNum > maxRetry { + return false + } + if order.CallbackNum == 0 { + return true + } + nextRunAt := order.UpdatedAt.StdTime().Add(callbackRetryDelay(order.CallbackNum)) + return !nextRunAt.After(now) +} + +func callbackRetryDelay(attempts int) time.Duration { + if attempts <= 0 { + return 0 + } + + delay := config.GetCallbackRetryBaseDuration() + maxDelay := 5 * time.Minute + for i := 1; i < attempts; i++ { + if delay >= maxDelay/2 { + return maxDelay + } + delay *= 2 + } + if delay > maxDelay { + return maxDelay + } + return delay +} diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go new file mode 100644 index 0000000..6869e70 --- /dev/null +++ b/src/mq/worker_test.go @@ -0,0 +1,191 @@ +package mq + +import ( + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/assimon/luuu/internal/testutil" + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" +) + +func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + order := &mdb.Orders{ + TradeId: "trade_expired", + OrderId: "order_expired", + Amount: 1, + ActualAmount: 1, + Token: "wallet_1", + Status: mdb.StatusWaitPay, + NotifyUrl: "https://merchant.example/callback", + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create expired order: %v", err) + } + if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil { + t.Fatalf("age expired order: %v", err) + } + if err := data.LockTransaction(order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { + t.Fatalf("lock expired order: %v", err) + } + + recentOrder := &mdb.Orders{ + TradeId: "trade_recent", + OrderId: "order_recent", + Amount: 1, + ActualAmount: 1.01, + Token: "wallet_1", + Status: mdb.StatusWaitPay, + NotifyUrl: "https://merchant.example/callback", + } + if err := dao.Mdb.Create(recentOrder).Error; err != nil { + t.Fatalf("create recent order: %v", err) + } + if err := data.LockTransaction(recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { + t.Fatalf("lock recent order: %v", err) + } + + processExpiredOrders() + + expired, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil { + t.Fatalf("reload expired order: %v", err) + } + if expired.Status != mdb.StatusExpired { + t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired) + } + lockTradeID, err := data.GetTradeIdByWalletAddressAndAmount(order.Token, order.ActualAmount) + if err != nil { + t.Fatalf("expired order lock lookup: %v", err) + } + if lockTradeID != "" { + t.Fatalf("expired order lock still exists: %s", lockTradeID) + } + + recent, err := data.GetOrderInfoByTradeId(recentOrder.TradeId) + if err != nil { + t.Fatalf("reload recent order: %v", err) + } + if recent.Status != mdb.StatusWaitPay { + t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay) + } + lockTradeID, err = data.GetTradeIdByWalletAddressAndAmount(recentOrder.Token, recentOrder.ActualAmount) + if err != nil { + t.Fatalf("recent order lock lookup: %v", err) + } + if lockTradeID != recentOrder.TradeId { + t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId) + } +} + +func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + order := &mdb.Orders{ + TradeId: "trade_paid", + OrderId: "order_paid", + Amount: 1, + ActualAmount: 1, + Token: "wallet_1", + Status: mdb.StatusPaySuccess, + NotifyUrl: "https://merchant.example/callback", + BlockTransactionId: "block_paid", + CallBackConfirm: mdb.CallBackConfirmNo, + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create paid order: %v", err) + } + if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil { + t.Fatalf("age paid order: %v", err) + } + + processExpiredOrders() + + current, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil { + t.Fatalf("reload paid order: %v", err) + } + if current.Status != mdb.StatusPaySuccess { + t.Fatalf("paid order status = %d, want %d", current.Status, mdb.StatusPaySuccess) + } + if current.BlockTransactionId != "block_paid" { + t.Fatalf("paid order block transaction id = %s, want block_paid", current.BlockTransactionId) + } +} + +func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + callbackLimiter = make(chan struct{}, 1) + callbackInflight = sync.Map{} + + var requestCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requestCount, 1) + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + + order := &mdb.Orders{ + TradeId: "trade_callback", + OrderId: "order_callback", + Amount: 1, + ActualAmount: 1, + Token: "wallet_1", + Status: mdb.StatusPaySuccess, + NotifyUrl: server.URL, + BlockTransactionId: "block_callback", + CallbackNum: 1, + CallBackConfirm: mdb.CallBackConfirmNo, + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create callback order: %v", err) + } + + dispatchPendingCallbacks() + time.Sleep(200 * time.Millisecond) + if got := atomic.LoadInt32(&requestCount); got != 0 { + t.Fatalf("unexpected callback count before backoff elapsed: %d", got) + } + + if err := dao.Mdb.Model(order).UpdateColumn("updated_at", time.Now().Add(-2*time.Second)).Error; err != nil { + t.Fatalf("age callback order: %v", err) + } + dispatchPendingCallbacks() + + waitFor(t, 3*time.Second, func() bool { + current, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil || current.ID <= 0 { + return false + } + return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 2 + }) + + if got := atomic.LoadInt32(&requestCount); got != 1 { + t.Fatalf("callback request count = %d, want 1", got) + } +} + +func waitFor(t *testing.T, timeout time.Duration, fn func() bool) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("condition not satisfied before timeout") +} diff --git a/src/util/constant/errno.go b/src/util/constant/errno.go index ff8df5c..372f676 100644 --- a/src/util/constant/errno.go +++ b/src/util/constant/errno.go @@ -1,17 +1,18 @@ package constant var Errno = map[int]string{ - 400: "系统错误", - 401: "签名认证错误", - 10001: "钱包地址已存在,请勿重复添加", - 10002: "支付交易已存在,请勿重复创建", - 10003: "无可用钱包地址,无法发起支付", - 10004: "支付金额有误, 无法满足最小支付单位", - 10005: "无可用金额通道", - 10006: "汇率计算错误", - 10007: "订单区块已处理", - 10008: "订单不存在", - 10009: "无法解析请求参数", + 400: "system error", + 401: "signature verification failed", + 10001: "wallet address already exists", + 10002: "order already exists", + 10003: "no available wallet address", + 10004: "invalid payment amount", + 10005: "no available amount channel", + 10006: "rate calculation failed", + 10007: "block transaction already processed", + 10008: "order does not exist", + 10009: "failed to parse request params", + 10010: "order status already changed", } var ( @@ -26,6 +27,7 @@ var ( OrderBlockAlreadyProcess = Err(10007) OrderNotExists = Err(10008) ParamsMarshalErr = Err(10009) + OrderStatusConflict = Err(10010) ) type RspError struct { From dea81caa61d05c8da713e269e2f3c082fbac1957 Mon Sep 17 00:00:00 2001 From: alphago9 Date: Mon, 30 Mar 2026 01:37:08 +0800 Subject: [PATCH 2/3] build(release): add tag-based release automation and version metadata --- .github/workflows/docker-alpine.yml | 44 ++++++++++++----- .github/workflows/release.yml | 62 +++++++++++++++++++++++ Dockerfile | 6 ++- RELEASING.md | 74 ++++++++++++++++++++++++++++ src/.goreleaser.yaml | 76 ++++++++++++++++++++--------- src/bootstrap/bootstrap.go | 23 ++++----- src/command/banner.go | 18 +++++++ src/command/http_server.go | 12 +++-- src/command/version.go | 26 ++++++++++ src/config/config.go | 31 ++++++++---- src/main.go | 9 ++-- 11 files changed, 312 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 src/command/banner.go create mode 100644 src/command/version.go diff --git a/.github/workflows/docker-alpine.yml b/.github/workflows/docker-alpine.yml index 13fa017..8616180 100644 --- a/.github/workflows/docker-alpine.yml +++ b/.github/workflows/docker-alpine.yml @@ -1,28 +1,46 @@ name: docker-alpine-epusdt -on: - workflow_dispatch: #github页面手动触发 - push: - branches: [ "main" ] -env: - IMAGE_NAME: epusdt #这是您的镜像名 -jobs: - push-docker-hub: +on: + workflow_dispatch: + push: + branches: + - "main" + +env: + IMAGE_NAME: epusdt + +jobs: + push-docker-hub: runs-on: ubuntu-latest env: TZ: Asia/Shanghai + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + + - name: Compute Build Metadata + id: meta + shell: bash + run: | + echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build && Push - uses: docker/build-push-action@v4 + + - name: Build And Push + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true - tags: | + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ steps.meta.outputs.commit }} + DATE=${{ steps.meta.outputs.date }} + tags: | ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c09aeb6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + goreleaser: + name: Build And Publish Binaries + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Ensure Tag Commit Is On Default Branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git fetch origin "$DEFAULT_BRANCH" --depth=1 + if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then + echo "tag ${GITHUB_REF_NAME} must reference a commit reachable from ${DEFAULT_BRANCH}" + exit 1 + fi + + - name: Set Up Go + uses: actions/setup-go@v6 + with: + go-version-file: src/go.mod + + - name: Install Cosign + uses: sigstore/cosign-installer@v4.1.1 + + - name: Install Syft + uses: anchore/sbom-action/download-syft@v0.24.0 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + workdir: src + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Attest Release Checksums + uses: actions/attest@v4 + with: + subject-checksums: src/dist/epusdt_${{ github.ref_name }}_checksums.txt diff --git a/Dockerfile b/Dockerfile index 0b4fa96..eca1c12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,13 +2,15 @@ FROM golang:alpine AS builder RUN apk add --no-cache --update git build-base ENV CGO_ENABLED=1 +ARG VERSION=0.0.0-dev +ARG COMMIT=none +ARG DATE=unknown WORKDIR /app COPY ./src/go.mod ./src/go.sum ./ RUN go mod download COPY ./src . -RUN go mod tidy \ - && go build -ldflags "-s -w" \ +RUN go build -ldflags "-s -w -X github.com/assimon/luuu/config.BuildVersion=${VERSION} -X github.com/assimon/luuu/config.BuildCommit=${COMMIT} -X github.com/assimon/luuu/config.BuildDate=${DATE}" \ -o epusdt . FROM alpine:latest AS runner diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..64cf7f8 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,74 @@ +# Releasing + +This repository publishes versioned binaries to GitHub Releases when a tag is pushed. + +## What gets published + +For each tag like `v1.2.3`, GitHub Actions builds release archives for: + +- `linux/amd64` +- `linux/arm64` +- `darwin/amd64` +- `darwin/arm64` +- `windows/amd64` +- `windows/arm64` + +Each archive includes: + +- the `epusdt` binary +- `src/.env.example` +- `src/static/` + +Windows artifacts are published as `.zip`. Other platforms are published as `.tar.gz`. + +Each release also publishes: + +- a checksum file +- a Sigstore keyless signature bundle for the checksum file +- archive SBOM documents generated by Syft +- a GitHub build provenance attestation for the checksum file + +## How to cut a release + +1. Make sure the release commit has already landed on the default branch. +2. Create an annotated tag: + +```bash +git tag -a v1.0.0 -m "release v1.0.0" +``` + +3. Push the tag: + +```bash +git push origin v1.0.0 +``` + +4. Wait for the `release` workflow to finish on GitHub. +5. Download the generated binaries from the GitHub Release page. + +## Verify a release + +Verify the checksum signature with Cosign: + +```bash +cosign verify-blob \ + --bundle epusdt_v1.0.0_checksums.txt.sigstore.json \ + epusdt_v1.0.0_checksums.txt +``` + +Verify the GitHub build provenance attestation: + +```bash +gh attestation verify epusdt_v1.0.0_checksums.txt -R GMwalletApp/epusdt +``` + +## Local validation + +Install `cosign` and `syft`, then run a snapshot build locally before pushing a tag: + +```bash +cd src +go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean +``` + +The resulting artifacts will be generated under `src/dist/`. diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index 205f477..8ca3083 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -1,44 +1,72 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com +version: 2 + project_name: epusdt + before: hooks: - # You may remove this if you don't use go modules. - #- go mod tidy - # you may remove this if you don't need go generate - #- go generate ./... + - go test ./... + builds: - - env: + - id: epusdt + main: . + binary: epusdt + env: - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }} goos: - - darwin - linux + - darwin - windows goarch: - amd64 - - arm - - 386 + - arm64 + archives: - - replacements: - darwin: Darwin - linux: Linux - windows: Windows - 386: i386 - amd64: x86_64 - id: epusdt - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" - format: tar.gz + - id: epusdt + ids: + - epusdt + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - .env.example - - static/* - - runtime/* + - static/** + format_overrides: + - goos: windows + formats: + - zip + checksum: - name_template: 'checksums.txt' + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + +signs: + - cmd: cosign + signature: "${artifact}.sigstore.json" + args: + - "sign-blob" + - "--bundle=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + +sboms: + - id: archives + artifacts: archive + snapshot: name_template: "{{ incpatch .Version }}-next" + +release: + github: + owner: GMwalletApp + name: epusdt + changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" diff --git a/src/bootstrap/bootstrap.go b/src/bootstrap/bootstrap.go index a615e9e..5452ee3 100644 --- a/src/bootstrap/bootstrap.go +++ b/src/bootstrap/bootstrap.go @@ -1,7 +1,8 @@ package bootstrap import ( - "github.com/assimon/luuu/command" + "sync" + "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/mq" @@ -10,15 +11,15 @@ import ( "github.com/assimon/luuu/util/log" ) -func Start() { - config.Init() - log.Init() - dao.Init() - mq.Start() - go telegram.BotStart() - go task.Start() +var initOnce sync.Once - if err := command.Execute(); err != nil { - panic(err) - } +func InitApp() { + initOnce.Do(func() { + config.Init() + log.Init() + dao.Init() + mq.Start() + go telegram.BotStart() + go task.Start() + }) } diff --git a/src/command/banner.go b/src/command/banner.go new file mode 100644 index 0000000..bbbf8d7 --- /dev/null +++ b/src/command/banner.go @@ -0,0 +1,18 @@ +package command + +import ( + "github.com/assimon/luuu/config" + "github.com/gookit/color" +) + +func printBanner() { + color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ") + color.Infof( + "Epusdt version(%s) commit(%s) built(%s) Powered by %s %s \n", + config.GetAppVersion(), + config.GetBuildCommit(), + config.GetBuildDate(), + "GMwalletApp", + "https://github.com/GMwalletApp/epusdt", + ) +} diff --git a/src/command/http_server.go b/src/command/http_server.go index f9e4dae..917737d 100644 --- a/src/command/http_server.go +++ b/src/command/http_server.go @@ -2,6 +2,12 @@ package command import ( "context" + "net/http" + "os" + "os/signal" + "time" + + "github.com/assimon/luuu/bootstrap" "github.com/assimon/luuu/config" "github.com/assimon/luuu/middleware" "github.com/assimon/luuu/route" @@ -12,10 +18,6 @@ import ( echoMiddleware "github.com/labstack/echo/v4/middleware" "github.com/spf13/cobra" "github.com/spf13/viper" - "net/http" - "os" - "os/signal" - "time" ) var httpCmd = &cobra.Command{ @@ -35,6 +37,8 @@ var startCmd = &cobra.Command{ Short: "启动", Long: "启动http服务", Run: func(cmd *cobra.Command, args []string) { + bootstrap.InitApp() + printBanner() HttpServerStart() }, } diff --git a/src/command/version.go b/src/command/version.go new file mode 100644 index 0000000..6394222 --- /dev/null +++ b/src/command/version.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + + "github.com/assimon/luuu/config" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print build version information", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), + "version: %s\ncommit: %s\nbuilt: %s\n", + config.GetAppVersion(), + config.GetBuildCommit(), + config.GetBuildDate(), + ) + return err + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/src/config/config.go b/src/config/config.go index f9cadb4..a13d7c0 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -11,15 +11,18 @@ import ( ) var ( - AppDebug bool - MysqlDns string - RuntimePath string - LogSavePath string - StaticPath string - TgBotToken string - TgProxy string - TgManage int64 - UsdtRate float64 + AppDebug bool + MysqlDns string + RuntimePath string + LogSavePath string + StaticPath string + TgBotToken string + TgProxy string + TgManage int64 + UsdtRate float64 + BuildVersion = "0.0.0-dev" + BuildCommit = "none" + BuildDate = "unknown" ) func Init() { @@ -65,7 +68,15 @@ func mustMkdir(path string) { } func GetAppVersion() string { - return "0.0.2" + return BuildVersion +} + +func GetBuildCommit() string { + return BuildCommit +} + +func GetBuildDate() string { + return BuildDate } func GetAppName() string { diff --git a/src/main.go b/src/main.go index f6015c2..522e672 100644 --- a/src/main.go +++ b/src/main.go @@ -1,8 +1,7 @@ package main import ( - "github.com/assimon/luuu/bootstrap" - "github.com/assimon/luuu/config" + "github.com/assimon/luuu/command" "github.com/gookit/color" ) @@ -12,7 +11,7 @@ func main() { color.Error.Println("[Start Server Err!!!] ", err) } }() - color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ") - color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "GMwalletApp", "https://github.com/GMwalletApp/epusdt") - bootstrap.Start() + if err := command.Execute(); err != nil { + panic(err) + } } From 682aa3907a4cbd828b5ffb08c3867409d7e7ef37 Mon Sep 17 00:00:00 2001 From: alphago9 Date: Tue, 31 Mar 2026 13:54:10 +0800 Subject: [PATCH 3/3] refactor: harden sqlite runtime and packaged startup flow - refine config loading with --config and current-directory .env - improve sqlite busy handling and runtime DB tuning - restore telegram payment notification and wallet input flow - add behavior-based config and callback recovery tests --- .gitignore | 5 +- DEVELOPMENT_STANDARD.md | 157 +++++++++++++++++++ REVIEW.md | 208 ++++++++++++++++++++++++++ src/.env.example | 4 +- src/command/http_server.go | 25 ++-- src/command/root.go | 11 +- src/config/config.go | 146 +++++++++++++++--- src/config/config_test.go | 118 +++++++++++++++ src/controller/comm/pay_controller.go | 4 +- src/internal/testutil/testdb.go | 7 +- src/model/dao/mdb_mysql.go | 2 +- src/model/dao/mdb_postgres.go | 2 +- src/model/dao/mdb_sqlite.go | 23 +-- src/model/dao/runtime_sqlite.go | 20 +-- src/model/dao/sqlite_config.go | 35 +++++ src/model/data/order_data.go | 15 +- src/model/service/pay_service.go | 2 +- src/model/service/task_service.go | 42 +++++- src/mq/worker.go | 75 ++++++++-- src/mq/worker_test.go | 72 +++++++++ src/telegram/handle.go | 93 +++++++++--- src/util/http_client/client.go | 5 +- src/util/log/log.go | 37 ++++- 23 files changed, 975 insertions(+), 133 deletions(-) create mode 100644 DEVELOPMENT_STANDARD.md create mode 100644 REVIEW.md create mode 100644 src/config/config_test.go create mode 100644 src/model/dao/sqlite_config.go diff --git a/.gitignore b/.gitignore index 88dddcc..cb03dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .idea/ src/runtime/ src/.env +src/*.db +src/*.db-shm +src/*.db-wal .DS_Store -.env \ No newline at end of file +.env diff --git a/DEVELOPMENT_STANDARD.md b/DEVELOPMENT_STANDARD.md new file mode 100644 index 0000000..553001f --- /dev/null +++ b/DEVELOPMENT_STANDARD.md @@ -0,0 +1,157 @@ +# Development Standard + +## Purpose + +This document defines the default development goals, review criteria, and testing constraints for changes in this repository. + +Before starting any implementation, use this document as a checklist. + +## Development Goals + +Every change should be judged against all of the following goals: + +1. Functional completeness + The target feature must actually work end to end, not just compile. + +2. No accidental teammate regression + Do not accidentally remove or override teammate capabilities that already exist on the base branch. + +3. Robustness + The code should handle normal failure cases, retries, state conflicts, and restart scenarios as reasonably as possible. + +4. Elegance + Prefer clear responsibilities, minimal coupling, and a small number of well-named moving parts. + +5. Maintainability + Avoid hidden behavior, confusing configuration, or implementation details that make future debugging harder. + +6. No dirty output + Never commit local runtime artifacts, local databases, caches, logs, or other development byproducts. + +7. No mojibake + Source files, messages, and docs should not contain broken encoding text. + +## Multi-Dimensional Self Review + +Each meaningful change should be evaluated across these dimensions: + +- functional completeness +- engineering implementation quality +- robustness +- elegance +- maintainability +- regression risk +- commit readiness +- test coverage quality + +Scoring should be honest. High scores require both working behavior and clean delivery quality. + +## Code Quality Rules + +### Redundancy + +Avoid redundant code unless duplication is clearly cheaper and safer than abstraction. + +The following are considered bad redundancy: + +- duplicate business logic with only tiny naming differences +- duplicate retry or state logic in multiple places +- helper functions that exist only to wrap one line without improving clarity + +The following are acceptable: + +- small data shapes for read optimization +- isolated helpers that remove repeated infrastructure setup +- compatibility shims with clear removal intent + +### Elegance + +Code is considered elegant when: + +- responsibilities are split cleanly +- names explain intent +- state transitions are explicit +- configuration is understandable +- behavior is observable through useful logs +- failure handling does not hide important problems + +### Robustness + +Code is considered robust when: + +- state changes are guarded by conditions where needed +- retries are controlled, not infinite +- restart recovery is possible where it matters +- local concurrency does not easily corrupt behavior +- failure in one side task does not silently break the main flow + +## Bug Standard + +Ask these questions before treating a change as finished: + +- Is there any blocking functional bug? +- Is there any likely edge-case bug? +- Is there any operational bug under realistic local concurrency? +- Is there any user-visible regression? + +If a bug is known, it must be called out explicitly in review notes. + +## Testing Rules + +### Required Principle + +Tests must validate behavior, not re-implement the code under test. + +### Forbidden Test Types + +Do not write: + +- invalid tests +- mirror tests +- tests that only repeat implementation branches line by line +- tests that only verify mocks interacted in the same order as the code was written +- tests that lock onto incidental internals instead of real behavior + +### Preferred Test Types + +Prefer: + +- end-to-end behavior tests +- state transition tests +- concurrency conflict tests +- retry and recovery tests +- expiration and timing behavior tests +- persistence and restart-oriented tests where relevant + +### Test Coverage Expectations + +Coverage quality matters more than raw coverage count. + +For core flows, tests should cover: + +- normal success path +- important failure path +- state conflict or idempotency path +- retry or delayed processing path where relevant + +## Review Checklist + +Before considering a change ready, confirm: + +- functionality is complete +- teammate capability was not accidentally lost +- no local artifact will be committed +- no mojibake exists +- no obvious redundant code exists +- no known blocking bug remains +- robustness is acceptable for the target deployment model +- tests are behavior-based and not mirror tests +- review notes clearly explain intentional semantic changes + +## Current Project Direction + +For this repository specifically, current review should assume: + +- single-instance deployment is the primary target unless stated otherwise +- runtime semantics may intentionally differ from older Redis and asynq behavior when the new SQLite design is deliberate +- any such semantic difference must be explained in merge or review notes diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..8f066fc --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,208 @@ +# Review + +## Scope + +This review checks `dev-runtime` against `dev` with the following standards: + +- Do not accidentally remove or override teammate payment capabilities already merged into `dev`. +- Do not submit local test artifacts or dirty files. +- Confirm Redis -> SQLite replacement is functionally complete for the intended single-instance target. +- Judge code quality, elegance, robustness, and maintainability. +- Check for real bugs, mojibake, and redundant code. +- Compare current quality with the previous `dev` implementation. + +These standards are also the default development constraints for future work: + +- no invalid tests +- no mirror tests +- prefer behavior-based tests over implementation-shaped tests +- explain intentional semantic changes in review notes +- verify dirty local artifacts are excluded before commit +- think about teammate-regression risk before coding, not only after coding + +## Files That Must Not Be Committed + +These local SQLite artifacts must never be committed: + +- `src/epusdt.db` +- `src/epusdt.db-shm` +- `src/epusdt.db-wal` + +They are now excluded by `.gitignore`. + +## Teammate Capabilities Still Preserved + +The following teammate payment capabilities are still present: + +- multi-currency order model: `src/model/mdb/orders_mdb.go` +- wallet address model: `src/model/mdb/wallet_address_mdb.go` +- native `TRX` transfer detection: `src/model/service/task_service.go` +- `TRC20 USDT` detection: `src/model/service/task_service.go` +- dynamic exchange-rate path: `src/model/service/order_service.go`, `src/config/config.go` +- `TRON_GRID_API_KEY` support: `src/config/config.go` + +Conclusion: teammate core payment capabilities were not accidentally removed. + +## Intentional Architecture Changes + +These are intentional refactors, not accidental overrides: + +- Redis runtime removed: `src/model/dao/rdb.go` +- asynq handlers removed: `src/mq/handle/callback_queue.go`, `src/mq/handle/order_expiration_queue.go` +- SQLite runtime lock introduced: `src/model/mdb/transaction_lock_mdb.go` +- SQLite scheduler introduced: `src/mq/queue.go`, `src/mq/worker.go` +- reservation moved from Redis key to SQLite unique constraint: `src/model/data/order_data.go` + +Conclusion: this branch keeps payment capability but changes the runtime implementation. + +## Previously Regressed Parts That Are Now Restored + +The following regressions have been restored in the current working tree: + +- rich Telegram payment notification template: `src/model/service/task_service.go` +- key chain-scan observability logs: `src/model/service/task_service.go` +- Chinese checkout error message: `src/model/service/pay_service.go` +- more reliable Telegram add-wallet flow: `src/telegram/handle.go` + +## Functional Completion + +For the single-instance target, the Redis -> SQLite replacement is complete across: + +- order creation +- amount reservation +- chain scan matching +- payment success processing +- order expiration +- callback scheduling +- Telegram notification + +Conclusion: feature work is complete for the intended single-instance model. + +## Mojibake Check + +No real mojibake was found in the source files checked during review. + +Files spot-checked: + +- `src/telegram/handle.go` +- `src/model/service/task_service.go` +- `src/model/service/pay_service.go` + +Note: some terminals may render UTF-8 Chinese poorly, but the source content itself is fine. + +## Redundant Code Check + +No obvious redundant business code was found. + +Recent helper additions are justified: + +- `PendingCallbackOrder` +- `expirableOrder` +- `configureSQLite` + +Conclusion: no clear "fix by duplication" smell was found. + +## Bug Assessment + +No confirmed blocking business bug is currently visible in the reviewed code path. + +Important risk that still needs attention: + +- SQLite main DB can still hit `SQLITE_BUSY` under concurrent polling and writes. + +This risk is now being mitigated by: + +- `src/model/dao/sqlite_config.go` +- `src/model/dao/mdb_sqlite.go` +- `src/model/dao/runtime_sqlite.go` +- lightweight busy retry in `src/mq/worker.go` + +Conclusion: no confirmed blocking functional bug, but SQLite concurrency remains a real operational boundary that should still be watched in runtime. + +## Robustness Assessment + +Strengths: + +- conditional state transitions prevent reviving expired orders +- reservation is now a persistent unique constraint instead of a temporary Redis key +- callback state is durable across process restarts +- behavior tests exist for key paths: + - `src/model/service/order_service_test.go` + - `src/mq/worker_test.go` +- callback polling now has a small targeted retry for transient SQLite busy errors + +Remaining limits: + +- robust for single-instance deployment +- not a final design for multi-instance deployment +- SQLite contention should still be watched in real runtime + +Conclusion: robust enough for single-instance use, not a direct multi-instance design. + +## Elegance Assessment + +Strengths: + +- runtime responsibility is cleaner with SQLite reservation + scheduler +- logging responsibility is split more clearly: + - `log_level` + - `http_access_log` + - `sql_debug` +- Telegram input flow is more practical for real clients +- SQLite tuning is extracted into one helper + +Tradeoffs: + +- callback semantics are no longer the same as the old immediate-asynq enqueue path +- current branch still has local uncommitted fixes that must be organized into a clean commit set + +Conclusion: elegant for the chosen single-instance direction, but must be explained clearly when merging. + +## Quality Compared With Previous Code + +Overall judgment: quality did not go down; it improved in the single-instance direction. + +Improvements: + +- stronger state-machine constraints +- better log control +- more durable runtime reservation +- better Telegram interaction resilience +- added behavior tests +- added restart-oriented callback recovery coverage +- reduced scheduler fragility under transient SQLite busy errors + +Changed semantics rather than degradation: + +- merchant callback is now polled by SQLite scheduler instead of immediate asynq enqueue + +Conclusion: quality is not lower, but runtime semantics changed and must be stated explicitly. + +## Self Score + +- functional completeness: 9.1/10 +- engineering implementation: 8.9/10 +- robustness: 8.7/10 +- elegance: 9.0/10 +- commit readiness: 8.8/10 + +Overall self score: 8.9/10 + +Main deductions: + +- current uncommitted fixes still need to be organized into a clean commit +- SQLite single-file concurrency boundary still exists +- callback semantics differ from `dev-payment` and require clear communication + +## Final Conclusion + +This code can be submitted, but only if all of the following are done: + +1. Do not commit local SQLite artifacts. +2. Include the current uncommitted fixes in a clean, intentional commit set. +3. Explain clearly in merge notes: + - what teammate capabilities were preserved + - what runtime behavior was intentionally refactored + - what semantics changed and why + +If those three conditions are met, this is not a hidden override of teammate code. It is a reviewed and documented refactor. diff --git a/src/.env.example b/src/.env.example index c429455..dbaf8fa 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,6 +1,8 @@ app_name=epusdt app_uri=https://dujiaoka.com -app_debug=false +log_level=info +http_access_log=false +sql_debug=false http_listen=:8000 static_path=/static diff --git a/src/command/http_server.go b/src/command/http_server.go index 917737d..411b06e 100644 --- a/src/command/http_server.go +++ b/src/command/http_server.go @@ -22,8 +22,8 @@ import ( var httpCmd = &cobra.Command{ Use: "http", - Short: "http服务", - Long: "http服务相关命令", + Short: "http service", + Long: "http service commands", Run: func(cmd *cobra.Command, args []string) { }, } @@ -34,8 +34,8 @@ func init() { var startCmd = &cobra.Command{ Use: "start", - Short: "启动", - Long: "启动http服务", + Short: "start", + Long: "start http service", Run: func(cmd *cobra.Command, args []string) { bootstrap.InitApp() printBanner() @@ -48,23 +48,22 @@ func HttpServerStart() { e := echo.New() e.HideBanner = true e.HTTPErrorHandler = customHTTPErrorHandler - // 中间件注册 + MiddlewareRegister(e) - // 路由注册 route.RegisterRoute(e) - // 静态目录注册 - e.Static(config.StaticPath, "static") + e.Static(config.StaticPath, config.StaticFilePath) + httpListen := viper.GetString("http_listen") go func() { if err = e.Start(httpListen); err != http.ErrServerClosed { log.Sugar.Error(err) } }() - // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. - // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, os.Kill) <-quit + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err = e.Shutdown(ctx); err != nil { @@ -72,16 +71,13 @@ func HttpServerStart() { } } -// MiddlewareRegister 中间件注册 func MiddlewareRegister(e *echo.Echo) { - if config.AppDebug { - e.Debug = true + if config.HTTPAccessLog { e.Use(echoMiddleware.Logger()) } e.Use(middleware.RequestUUID()) } -// customHTTPErrorHandler 默认消息提示 func customHTTPErrorHandler(err error, e echo.Context) { code := http.StatusInternalServerError msg := "server error" @@ -99,5 +95,4 @@ func customHTTPErrorHandler(err error, e echo.Context) { resp.Message = he.Msg } _ = e.JSON(http.StatusOK, resp) - return } diff --git a/src/command/root.go b/src/command/root.go index 7d73bfa..3502187 100644 --- a/src/command/root.go +++ b/src/command/root.go @@ -1,6 +1,11 @@ package command -import "github.com/spf13/cobra" +import ( + "github.com/assimon/luuu/config" + "github.com/spf13/cobra" +) + +var configPath string var rootCmd = &cobra.Command{} @@ -9,5 +14,9 @@ func Execute() error { } func init() { + rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env") + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + config.SetConfigPath(configPath) + } rootCmd.AddCommand(httpCmd) } diff --git a/src/config/config.go b/src/config/config.go index cfe30b0..fb221d1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "log" "net/url" @@ -15,37 +16,50 @@ import ( ) var ( - AppDebug bool - MysqlDns string - RuntimePath string - LogSavePath string - StaticPath string - TgBotToken string - TgProxy string - TgManage int64 - UsdtRate float64 - RateApiUrl string - TRON_GRID_API_KEY string - BuildVersion = "0.0.0-dev" - BuildCommit = "none" - BuildDate = "unknown" + HTTPAccessLog bool + SQLDebug bool + LogLevel string + MysqlDns string + RuntimePath string + LogSavePath string + StaticPath string + StaticFilePath string + TgBotToken string + TgProxy string + TgManage int64 + UsdtRate float64 + RateApiUrl string + TRON_GRID_API_KEY string + BuildVersion = "0.0.0-dev" + BuildCommit = "none" + BuildDate = "unknown" + configRootPath string + explicitConfigPath string ) +func SetConfigPath(path string) { + explicitConfigPath = strings.TrimSpace(path) +} + func Init() { - viper.AddConfigPath("./") - viper.SetConfigFile(".env") - err := viper.ReadInConfig() + configPath, err := resolveConfigFilePath() if err != nil { panic(err) } - gwd, err := os.Getwd() + configRootPath = filepath.Dir(configPath) + + viper.SetConfigFile(configPath) + err = viper.ReadInConfig() if err != nil { panic(err) } - AppDebug = viper.GetBool("app_debug") - StaticPath = viper.GetString("static_path") - RuntimePath = fmt.Sprintf("%s%s", gwd, viper.GetString("runtime_root_path")) - LogSavePath = fmt.Sprintf("%s%s", RuntimePath, viper.GetString("log_save_path")) + HTTPAccessLog = viper.GetBool("http_access_log") + SQLDebug = viper.GetBool("sql_debug") + LogLevel = normalizeLogLevel(viper.GetString("log_level")) + StaticPath = normalizeStaticURLPath(viper.GetString("static_path")) + StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/")) + RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime")) + LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs")) mustMkdir(RuntimePath) mustMkdir(LogSavePath) MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", @@ -67,6 +81,79 @@ func mustMkdir(path string) { } } +func normalizeLogLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug", "info", "warn", "error": + return strings.ToLower(strings.TrimSpace(level)) + default: + return "info" + } +} + +func normalizeStaticURLPath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return "/static" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func resolvePathFromBase(basePath string, path string, fallback string) string { + path = strings.TrimSpace(path) + if path == "" { + return fallback + } + if filepath.IsAbs(path) { + return path + } + path = strings.TrimPrefix(path, "/") + path = strings.TrimPrefix(path, "\\") + return filepath.Join(basePath, filepath.FromSlash(path)) +} + +func resolveConfigFilePath() (string, error) { + if explicitConfigPath != "" { + return normalizeConfiguredPath(explicitConfigPath) + } + if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" { + return normalizeConfiguredPath(envPath) + } + return normalizeConfiguredPath(".env") +} + +func normalizeConfiguredPath(input string) (string, error) { + path := strings.TrimSpace(input) + if path == "" { + path = ".env" + } + if !filepath.IsAbs(path) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + path = filepath.Join(cwd, path) + } + + info, err := os.Stat(path) + if err == nil && info.IsDir() { + path = filepath.Join(path, ".env") + info, err = os.Stat(path) + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("config file not found: %s", path) + } + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("config path must point to a file, got directory: %s", path) + } + return path, nil +} + func GetAppVersion() string { return BuildVersion } @@ -194,7 +281,20 @@ func GetRuntimeSqlitePath() string { if filepath.IsAbs(filename) { return filename } - return filepath.Join(RuntimePath, filename) + filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\") + return filepath.Join(RuntimePath, filepath.FromSlash(filename)) +} + +func GetPrimarySqlitePath() string { + filename := strings.TrimSpace(viper.GetString("sqlite_database_filename")) + if filename == "" { + return filepath.Join(configRootPath, "epusdt.db") + } + if filepath.IsAbs(filename) { + return filename + } + filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\") + return filepath.Join(configRootPath, filepath.FromSlash(filename)) } func GetQueueConcurrency() int { diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..00b65b5 --- /dev/null +++ b/src/config/config_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) { + t.Helper() + + root := t.TempDir() + configPath := filepath.Join(root, "custom.env") + if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + got, err := normalizeConfiguredPath(configPath) + if err != nil { + t.Fatalf("normalize explicit file: %v", err) + } + if got != configPath { + t.Fatalf("config path = %s, want %s", got, configPath) + } +} + +func TestNormalizeConfiguredPathUsesExplicitDirectory(t *testing.T) { + t.Helper() + + root := t.TempDir() + configPath := filepath.Join(root, ".env") + if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + got, err := normalizeConfiguredPath(root) + if err != nil { + t.Fatalf("normalize explicit directory: %v", err) + } + if got != configPath { + t.Fatalf("config path = %s, want %s", got, configPath) + } +} + +func TestResolveConfigFilePathUsesCurrentDirectoryByDefault(t *testing.T) { + t.Helper() + + oldCwd, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + defer func() { _ = os.Chdir(oldCwd) }() + + root := t.TempDir() + configPath := filepath.Join(root, ".env") + if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + + t.Setenv("EPUSDT_CONFIG", "") + SetConfigPath("") + + got, err := resolveConfigFilePath() + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + if got != configPath { + t.Fatalf("config path = %s, want %s", got, configPath) + } +} + +func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) { + t.Helper() + + oldCwd, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + defer func() { _ = os.Chdir(oldCwd) }() + + root := t.TempDir() + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + + envDir := filepath.Join(root, "from-env") + if err := os.MkdirAll(envDir, 0o755); err != nil { + t.Fatalf("mkdir env dir: %v", err) + } + envPath := filepath.Join(envDir, ".env") + if err := os.WriteFile(envPath, []byte("app_name=env\n"), 0o644); err != nil { + t.Fatalf("write env config: %v", err) + } + + flagDir := filepath.Join(root, "from-flag") + if err := os.MkdirAll(flagDir, 0o755); err != nil { + t.Fatalf("mkdir flag dir: %v", err) + } + flagPath := filepath.Join(flagDir, ".env") + if err := os.WriteFile(flagPath, []byte("app_name=flag\n"), 0o644); err != nil { + t.Fatalf("write flag config: %v", err) + } + + t.Setenv("EPUSDT_CONFIG", envDir) + SetConfigPath(flagDir) + defer SetConfigPath("") + + got, err := resolveConfigFilePath() + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + if got != flagPath { + t.Fatalf("config path = %s, want %s", got, flagPath) + } +} diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 258dffa..328ca20 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -1,13 +1,13 @@ package comm import ( - "fmt" "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/service" "github.com/labstack/echo/v4" "html/template" "net/http" + "path/filepath" ) // CheckoutCounter 收银台 @@ -17,7 +17,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { if err != nil { return ctx.String(http.StatusOK, err.Error()) } - tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html")) + tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html")) if err != nil { return ctx.String(http.StatusOK, err.Error()) } diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go index 591c9be..7c68ea2 100644 --- a/src/internal/testutil/testdb.go +++ b/src/internal/testutil/testdb.go @@ -7,8 +7,10 @@ import ( "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" + appLog "github.com/assimon/luuu/util/log" "github.com/libtnb/sqlite" "github.com/spf13/viper" + "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" @@ -27,8 +29,11 @@ func SetupTestDatabases(t testing.TB) func() { viper.Set("queue_poll_interval_ms", 50) viper.Set("api_auth_token", "test-token") - config.AppDebug = false + config.HTTPAccessLog = false + config.SQLDebug = false + config.LogLevel = "error" config.UsdtRate = 0 + appLog.Sugar = zap.NewNop().Sugar() mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db")) runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db")) diff --git a/src/model/dao/mdb_mysql.go b/src/model/dao/mdb_mysql.go index 3e27f37..a522971 100644 --- a/src/model/dao/mdb_mysql.go +++ b/src/model/dao/mdb_mysql.go @@ -29,7 +29,7 @@ func MysqlInit() error { // panic(err) return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } sqlDB, err := Mdb.DB() diff --git a/src/model/dao/mdb_postgres.go b/src/model/dao/mdb_postgres.go index d14c65a..98a9ea1 100644 --- a/src/model/dao/mdb_postgres.go +++ b/src/model/dao/mdb_postgres.go @@ -40,7 +40,7 @@ func PostgreSQLInit() error { return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } sqlDB, err := Mdb.DB() diff --git a/src/model/dao/mdb_sqlite.go b/src/model/dao/mdb_sqlite.go index 827ee92..9fc48f6 100644 --- a/src/model/dao/mdb_sqlite.go +++ b/src/model/dao/mdb_sqlite.go @@ -1,6 +1,7 @@ package dao import ( + "os" "path/filepath" "github.com/assimon/luuu/config" @@ -16,9 +17,10 @@ import ( // SqliteInit 数据库初始化 func SqliteInit() error { var err error - dbFilename := "./conf/.db" - if dbfile := viper.GetString("sqlite_database_filename"); len(dbfile) > 0 { - dbFilename = filepath.Base(dbfile) + dbFilename := config.GetPrimarySqlitePath() + if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil { + color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err) + return err } color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename) Mdb, err = openDB(dbFilename, &gorm.Config{ @@ -33,22 +35,11 @@ func SqliteInit() error { // panic(err) return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } - sqlDB, err := Mdb.DB() - if err != nil { - color.Red.Printf("[store_db] sqlite get DB,err=%s\n", err) - // panic(err) - return err - } - // sqlDB.SetMaxIdleConns(viper.GetInt("sqlite_max_idle_conns")) - // sqlDB.SetMaxOpenConns(viper.GetInt("sqlite_max_open_conns")) - // sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("sqlite_max_life_time"))) - err = sqlDB.Ping() - if err != nil { + if _, err = configureSQLite(Mdb, 1); err != nil { color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error()) - // panic(err) return err } log.Sugar.Debug("[store_db] sqlite connDB success") diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go index 29a3597..08a942b 100644 --- a/src/model/dao/runtime_sqlite.go +++ b/src/model/dao/runtime_sqlite.go @@ -27,12 +27,6 @@ func RuntimeInit() error { return err } - sqlDB, err := RuntimeDB.DB() - if err != nil { - color.Red.Printf("[runtime_db] sqlite get DB,err=%s\n", err) - return err - } - concurrency := config.GetQueueConcurrency() if concurrency < 2 { concurrency = 2 @@ -40,19 +34,7 @@ func RuntimeInit() error { if concurrency > 16 { concurrency = 16 } - sqlDB.SetMaxOpenConns(concurrency) - sqlDB.SetMaxIdleConns(1) - - if err = RuntimeDB.Exec("PRAGMA journal_mode=WAL;").Error; err != nil { - return err - } - if err = RuntimeDB.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil { - return err - } - if err = RuntimeDB.Exec("PRAGMA busy_timeout=5000;").Error; err != nil { - return err - } - if err = sqlDB.Ping(); err != nil { + if _, err = configureSQLite(RuntimeDB, concurrency); err != nil { color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error()) return err } diff --git a/src/model/dao/sqlite_config.go b/src/model/dao/sqlite_config.go new file mode 100644 index 0000000..9669600 --- /dev/null +++ b/src/model/dao/sqlite_config.go @@ -0,0 +1,35 @@ +package dao + +import ( + "database/sql" + + "gorm.io/gorm" +) + +func configureSQLite(db *gorm.DB, maxOpenConns int) (*sql.DB, error) { + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + if maxOpenConns <= 0 { + maxOpenConns = 1 + } + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(1) + + if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil { + return nil, err + } + if err := db.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil { + return nil, err + } + if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil { + return nil, err + } + if err := sqlDB.Ping(); err != nil { + return nil, err + } + + return sqlDB, nil +} diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index f5484f8..daa03c8 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -8,6 +8,7 @@ import ( "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" + "github.com/dromara/carbon/v2" "github.com/shopspring/decimal" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -15,6 +16,13 @@ import ( var ErrTransactionLocked = errors.New("transaction amount is already locked") +type PendingCallbackOrder struct { + TradeId string `gorm:"column:trade_id"` + CallbackNum int `gorm:"column:callback_num"` + CallBackConfirm int `gorm:"column:callback_confirm"` + UpdatedAt carbon.Time `gorm:"column:updated_at"` +} + func normalizeLockAmount(amount float64) (int64, string) { value := decimal.NewFromFloat(amount).Round(2) return value.Shift(2).IntPart(), value.StringFixed(2) @@ -63,10 +71,11 @@ func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingReques return result.RowsAffected > 0, result.Error } -// GetPendingCallbackOrders returns orders that still need callback delivery. -func GetPendingCallbackOrders(maxRetry int, limit int) ([]mdb.Orders, error) { - var orders []mdb.Orders +// GetPendingCallbackOrders returns the minimal callback scheduling state. +func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) { + var orders []PendingCallbackOrder query := dao.Mdb.Model(&mdb.Orders{}). + Select("trade_id", "callback_num", "callback_confirm", "updated_at"). Where("callback_num <= ?", maxRetry). Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("status = ?", mdb.StatusPaySuccess). diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index f10cc39..0be38f7 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -16,7 +16,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo return nil, err } if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { - return nil, errors.New("pending order does not exist or has expired") + return nil, errors.New("不存在待支付订单或已过期") } resp := &response.CheckoutCounterResponse{ diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 587e70a..a0bd8bc 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -8,8 +8,8 @@ import ( "strings" "sync" - tron "github.com/assimon/luuu/crypto" "github.com/assimon/luuu/config" + tron "github.com/assimon/luuu/crypto" "github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" @@ -73,7 +73,14 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) { panic("TRX API response indicates failure") } - for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() { + transfers := gjson.GetBytes(resp.Body(), "data").Array() + if len(transfers) == 0 { + log.Sugar.Debugf("[TRX][%s] no transfer records found", address) + return + } + log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers)) + + for i, transfer := range transfers { if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" { continue } @@ -108,8 +115,10 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) { panic(err) } if tradeID == "" { + log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount) continue } + log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount) order, err := data.GetOrderInfoByTradeId(tradeID) if err != nil { @@ -139,6 +148,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) { } sendPaymentNotification(order) + log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID) } } @@ -174,7 +184,14 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { panic("TRC20 API response indicates failure") } - for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() { + transfers := gjson.GetBytes(resp.Body(), "data").Array() + if len(transfers) == 0 { + log.Sugar.Debugf("[TRC20][%s] no transfer records found", address) + return + } + log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers)) + + for i, transfer := range transfers { if transfer.Get("token_info.address").String() != TRC20_USDT_ID { continue } @@ -200,8 +217,10 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { panic(err) } if tradeID == "" { + log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount) continue } + log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount) order, err := data.GetOrderInfoByTradeId(tradeID) if err != nil { @@ -231,18 +250,29 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { } sendPaymentNotification(order) + log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID) } } func sendPaymentNotification(order *mdb.Orders) { msg := fmt.Sprintf( - "Payment received\nTrade ID: %s\nOrder ID: %s\nOrder Amount: %.2f %s\nReceived Amount: %.2f %s\nAddress: %s\nCreated At: %s\nPaid At: %s", - order.TradeId, - order.OrderId, + "🎉 收款成功通知\n\n"+ + "💰 金额信息\n"+ + "├ 订单金额:%.2f %s\n"+ + "└ 实际到账:%.2f %s\n\n"+ + "📋 订单信息\n"+ + "├ 交易号:%s\n"+ + "├ 订单号:%s\n"+ + "└ 钱包地址:%s\n\n"+ + "⏰ 时间信息\n"+ + "├ 创建时间:%s\n"+ + "└ 支付时间:%s", order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), + order.TradeId, + order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString(), diff --git a/src/mq/worker.go b/src/mq/worker.go index 4f64af1..c18d763 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -3,6 +3,7 @@ package mq import ( "errors" "net/http" + "strings" "time" "github.com/assimon/luuu/config" @@ -17,6 +18,16 @@ import ( const batchSize = 100 +const sqliteBusyRetryAttempts = 3 + +type expirableOrder struct { + ID uint64 `gorm:"column:id"` + TradeId string `gorm:"column:trade_id"` + ReceiveAddress string `gorm:"column:receive_address"` + Token string `gorm:"column:token"` + ActualAmount float64 `gorm:"column:actual_amount"` +} + func runOrderExpirationLoop() { runLoop("order_expiration", processExpiredOrders) } @@ -51,13 +62,16 @@ func safeRun(name string, fn func()) { func processExpiredOrders() { expirationCutoff := time.Now().Add(-config.GetOrderExpirationTimeDuration()) for { - var orders []mdb.Orders - err := dao.Mdb.Model(&mdb.Orders{}). - Where("status = ?", mdb.StatusWaitPay). - Where("created_at <= ?", expirationCutoff). - Order("id asc"). - Limit(batchSize). - Find(&orders).Error + var orders []expirableOrder + err := withSQLiteBusyRetry(func() error { + return dao.Mdb.Model(&mdb.Orders{}). + Select("id", "trade_id", "receive_address", "token", "actual_amount"). + Where("status = ?", mdb.StatusWaitPay). + Where("created_at <= ?", expirationCutoff). + Order("id asc"). + Limit(batchSize). + Find(&orders).Error + }) if err != nil { log.Sugar.Errorf("[mq] query expired orders failed: %v", err) return @@ -88,7 +102,12 @@ func processExpiredOrders() { func dispatchPendingCallbacks() { maxRetry := config.GetOrderNoticeMaxRetry() - orders, err := data.GetPendingCallbackOrders(maxRetry, batchSize) + var orders []data.PendingCallbackOrder + err := withSQLiteBusyRetry(func() error { + var innerErr error + orders, innerErr = data.GetPendingCallbackOrders(maxRetry, batchSize) + return innerErr + }) if err != nil { log.Sugar.Errorf("[mq] query callback orders failed: %v", err) return @@ -99,29 +118,30 @@ func dispatchPendingCallbacks() { if !isCallbackDue(&order, now, maxRetry) { continue } - if _, loaded := callbackInflight.LoadOrStore(order.TradeId, struct{}{}); loaded { + tradeID := order.TradeId + if _, loaded := callbackInflight.LoadOrStore(tradeID, struct{}{}); loaded { continue } select { case callbackLimiter <- struct{}{}: - go processCallback(order) + go processCallback(tradeID) default: - callbackInflight.Delete(order.TradeId) + callbackInflight.Delete(tradeID) return } } } -func processCallback(order mdb.Orders) { +func processCallback(tradeID string) { defer func() { <-callbackLimiter - callbackInflight.Delete(order.TradeId) + callbackInflight.Delete(tradeID) }() - freshOrder, err := data.GetOrderInfoByTradeId(order.TradeId) + freshOrder, err := data.GetOrderInfoByTradeId(tradeID) if err != nil { - log.Sugar.Errorf("[mq] reload callback order failed, trade_id=%s, err=%v", order.TradeId, err) + log.Sugar.Errorf("[mq] reload callback order failed, trade_id=%s, err=%v", tradeID, err) return } if freshOrder.ID <= 0 || freshOrder.Status != mdb.StatusPaySuccess || freshOrder.CallBackConfirm != mdb.CallBackConfirmNo { @@ -180,7 +200,30 @@ func cleanupExpiredTransactionLocks() { } } -func isCallbackDue(order *mdb.Orders, now time.Time, maxRetry int) bool { +func withSQLiteBusyRetry(fn func() error) error { + var err error + for attempt := 1; attempt <= sqliteBusyRetryAttempts; attempt++ { + err = fn() + if err == nil { + return nil + } + if !isSQLiteBusyError(err) || attempt == sqliteBusyRetryAttempts { + return err + } + time.Sleep(time.Duration(attempt*25) * time.Millisecond) + } + return err +} + +func isSQLiteBusyError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "database is locked") || strings.Contains(msg, "sqlite_busy") +} + +func isCallbackDue(order *data.PendingCallbackOrder, now time.Time, maxRetry int) bool { if order.CallBackConfirm != mdb.CallBackConfirmNo { return false } diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index b838a40..b67804e 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -185,6 +185,78 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) { } } +func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + callbackLimiter = make(chan struct{}, 1) + callbackInflight = sync.Map{} + + var requestCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := atomic.AddInt32(&requestCount, 1) + if attempt == 1 { + http.Error(w, "retry later", http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + + order := &mdb.Orders{ + TradeId: "trade_callback_restart", + OrderId: "order_callback_restart", + Amount: 1, + Currency: "CNY", + ActualAmount: 1, + ReceiveAddress: "wallet_restart", + Token: "USDT", + Status: mdb.StatusPaySuccess, + NotifyUrl: server.URL, + BlockTransactionId: "block_callback_restart", + CallbackNum: 0, + CallBackConfirm: mdb.CallBackConfirmNo, + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create callback order: %v", err) + } + + dispatchPendingCallbacks() + + waitFor(t, 3*time.Second, func() bool { + current, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil || current.ID <= 0 { + return false + } + return current.CallBackConfirm == mdb.CallBackConfirmNo && current.CallbackNum == 1 + }) + + if got := atomic.LoadInt32(&requestCount); got != 1 { + t.Fatalf("first callback request count = %d, want 1", got) + } + + callbackLimiter = make(chan struct{}, 1) + callbackInflight = sync.Map{} + + if err := dao.Mdb.Model(order).UpdateColumn("updated_at", time.Now().Add(-2*time.Second)).Error; err != nil { + t.Fatalf("age callback order for retry: %v", err) + } + + dispatchPendingCallbacks() + + waitFor(t, 3*time.Second, func() bool { + current, err := data.GetOrderInfoByTradeId(order.TradeId) + if err != nil || current.ID <= 0 { + return false + } + return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 2 + }) + + if got := atomic.LoadInt32(&requestCount); got != 2 { + t.Fatalf("total callback request count = %d, want 2", got) + } +} + func waitFor(t *testing.T, timeout time.Duration, fn func() bool) { t.Helper() diff --git a/src/telegram/handle.go b/src/telegram/handle.go index 3b5f008..72658e7 100644 --- a/src/telegram/handle.go +++ b/src/telegram/handle.go @@ -2,6 +2,8 @@ package telegram import ( "fmt" + "sync" + "time" "github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/mdb" @@ -11,25 +13,52 @@ import ( ) const ( - ReplayAddWallet = "请发给我一个合法的钱包地址" + ReplayAddWallet = "请发给我一个合法的钱包地址" + pendingWalletAddressTTL = 5 * time.Minute ) +type pendingWalletAddressState struct { + RequestedAt time.Time +} + +var pendingWalletAddressUsers sync.Map + func OnTextMessageHandle(c tb.Context) error { - if c.Message().ReplyTo.Text == ReplayAddWallet { - defer bots.Delete(c.Message().ReplyTo) - msgText := c.Message().Text - if !isValidTronAddress(msgText) { - _ = c.Send(fmt.Sprintf("钱包[%s]添加失败: 非Tron地址!", msgText)) - return nil - } - _, err := data.AddWalletAddress(msgText) - if err != nil { - return c.Send(err.Error()) - } - _ = c.Send(fmt.Sprintf("钱包[%s]添加成功!", msgText)) - return WalletList(c) + msg := c.Message() + if msg == nil { + return nil } - return nil + + sender := c.Sender() + senderID := int64(0) + if sender != nil { + senderID = sender.ID + } + + isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet + isPendingFlow := isWalletAddressPending(senderID) + if !isReplyFlow && !isPendingFlow { + return nil + } + + if isReplyFlow { + defer bots.Delete(msg.ReplyTo) + } + + msgText := msg.Text + if !isValidTronAddress(msgText) { + _ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 TRON 地址", msgText)) + return nil + } + + _, err := data.AddWalletAddress(msgText) + if err != nil { + return c.Send(err.Error()) + } + pendingWalletAddressUsers.Delete(senderID) + + _ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText)) + return WalletList(c) } func WalletList(c tb.Context) error { @@ -37,29 +66,35 @@ func WalletList(c tb.Context) error { if err != nil { return err } + var btnList [][]tb.InlineButton for _, wallet := range wallets { status := "已启用✅" if wallet.Status == mdb.TokenStatusDisable { status = "已禁用🚫" } - var temp []tb.InlineButton + btnInfo := tb.InlineButton{ Unique: wallet.Address, - Text: fmt.Sprintf("%s[%s]", wallet.Address, status), + Text: fmt.Sprintf("%s [%s]", wallet.Address, status), Data: strutil.MustString(wallet.ID), } bots.Handle(&btnInfo, WalletInfo) - btnList = append(btnList, append(temp, btnInfo)) + btnList = append(btnList, []tb.InlineButton{btnInfo}) } + addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"} bots.Handle(&addBtn, func(c tb.Context) error { + if sender := c.Sender(); sender != nil { + pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()}) + } return c.Send(ReplayAddWallet, &tb.ReplyMarkup{ ForceReply: true, }) }) btnList = append(btnList, []tb.InlineButton{addBtn}) - return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{ + + return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{ InlineKeyboard: btnList, }) } @@ -70,6 +105,7 @@ func WalletInfo(c tb.Context) error { if err != nil { return c.Send(err.Error()) } + enableBtn := tb.InlineButton{ Text: "启用", Unique: "enableBtn", @@ -89,10 +125,12 @@ func WalletInfo(c tb.Context) error { Text: "返回", Unique: "WalletList", } + bots.Handle(&enableBtn, EnableWallet) bots.Handle(&disableBtn, DisableWallet) bots.Handle(&delBtn, DelWallet) bots.Handle(&backBtn, WalletList) + return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{ { enableBtn, @@ -140,3 +178,20 @@ func DelWallet(c tb.Context) error { } return WalletList(c) } + +func isWalletAddressPending(userID int64) bool { + if userID <= 0 { + return false + } + value, ok := pendingWalletAddressUsers.Load(userID) + if !ok { + return false + } + + state, ok := value.(pendingWalletAddressState) + if !ok || time.Since(state.RequestedAt) > pendingWalletAddressTTL { + pendingWalletAddressUsers.Delete(userID) + return false + } + return true +} diff --git a/src/util/http_client/client.go b/src/util/http_client/client.go index bee9bc6..af440bc 100644 --- a/src/util/http_client/client.go +++ b/src/util/http_client/client.go @@ -1,8 +1,9 @@ package http_client import ( - "github.com/go-resty/resty/v2" "time" + + "github.com/go-resty/resty/v2" ) // GetHttpClient 获取请求客户端 @@ -13,6 +14,6 @@ func GetHttpClient(proxys ...string) *resty.Client { proxy := proxys[0] client.SetProxy(proxy) } - client.SetTimeout(time.Second * 5) + client.SetTimeout(time.Second * 10) return client } diff --git a/src/util/log/log.go b/src/util/log/log.go index f30e893..58e148d 100644 --- a/src/util/log/log.go +++ b/src/util/log/log.go @@ -7,27 +7,37 @@ import ( "github.com/spf13/viper" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "os" "time" ) var Sugar *zap.SugaredLogger func Init() { - writeSyncer := getLogWriter() - encoder := getEncoder() - core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) + level := getLogLevel() + core := zapcore.NewTee( + zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level), + zapcore.NewCore(getFileEncoder(), getFileWriter(), level), + ) logger := zap.New(core, zap.AddCaller()) Sugar = logger.Sugar() } -func getEncoder() zapcore.Encoder { +func getFileEncoder() zapcore.Encoder { encoderConfig := zap.NewProductionEncoderConfig() encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder return zapcore.NewJSONEncoder(encoderConfig) } -func getLogWriter() zapcore.WriteSyncer { +func getConsoleEncoder() zapcore.Encoder { + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + return zapcore.NewConsoleEncoder(encoderConfig) +} + +func getFileWriter() zapcore.WriteSyncer { file := fmt.Sprintf("%s/log_%s.log", config.LogSavePath, time.Now().Format("20060102")) @@ -40,3 +50,20 @@ func getLogWriter() zapcore.WriteSyncer { } return zapcore.AddSync(lumberJackLogger) } + +func getConsoleWriter() zapcore.WriteSyncer { + return zapcore.Lock(os.Stdout) +} + +func getLogLevel() zapcore.Level { + switch config.LogLevel { + case "debug": + return zapcore.DebugLevel + case "warn": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +}