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 {