refactor: replace redis runtime and queues with sqlite-backed scheduler

This commit is contained in:
alphago9
2026-03-29 17:26:36 +08:00
parent d6e7927605
commit fc8d58024e
22 changed files with 1193 additions and 446 deletions
+16 -37
View File
@@ -1,80 +1,59 @@
app_name=epusdt app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com app_uri=https://dujiaoka.com
#是否开启debug,默认false
app_debug=false app_debug=false
#http服务监听端口
http_listen=:8000 http_listen=:8000
#静态资源文件目录
static_path=/static static_path=/static
#缓存路径
runtime_root_path=/runtime runtime_root_path=/runtime
#日志配置
log_save_path=/logs log_save_path=/logs
log_max_size=32 log_max_size=32
log_max_age=7 log_max_age=7
max_backups=3 max_backups=3
#数据库类型,默认为mysql类型,可配置项: postgres,mysql,sqlite # supported values: postgres,mysql,sqlite
db_type=sqlite db_type=sqlite
# sqlite配置 # sqlite primary database config
sqlite_database_filename= sqlite_database_filename=
sqlite_table_prefix= sqlite_table_prefix=
# postgres配置 # postgres config
postgres_host=127.0.0.1 postgres_host=127.0.0.1
postgres_port=3306 postgres_port=3306
postgres_user=mysql账号 postgres_user=mysql_user
postgres_passwd=mysql密码 postgres_passwd=mysql_password
postgres_database=数据库 postgres_database=database_name
postgres_table_prefix= postgres_table_prefix=
postgres_max_idle_conns=10 postgres_max_idle_conns=10
postgres_max_open_conns=100 postgres_max_open_conns=100
postgres_max_life_time=6 postgres_max_life_time=6
# mysql配置 # mysql config
mysql_host=127.0.0.1 mysql_host=127.0.0.1
mysql_port=3306 mysql_port=3306
mysql_user=mysql账号 mysql_user=mysql_user
mysql_passwd=mysql密码 mysql_passwd=mysql_password
mysql_database=数据库 mysql_database=database_name
mysql_table_prefix= mysql_table_prefix=
mysql_max_idle_conns=10 mysql_max_idle_conns=10
mysql_max_open_conns=100 mysql_max_open_conns=100
mysql_max_life_time=6 mysql_max_life_time=6
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# redis配置 # background scheduler config
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
# 消息队列配置
queue_concurrency=10 queue_concurrency=10
queue_level_critical=6 queue_poll_interval_ms=1000
queue_level_default=3 callback_retry_base_seconds=5
queue_level_low=1
#机器人Apitoken
tg_bot_token= tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy= tg_proxy=
#管理员userid
tg_manage= tg_manage=
#api接口认证token
api_auth_token= api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10 order_expiration_time=10
#订单回调失败最大重试次数
order_notice_max_retry=0 order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4) forced_usdt_rate=
forced_usdt_rate=
+2 -13
View File
@@ -10,26 +10,15 @@ import (
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
) )
// Start 服务启动
func Start() { func Start() {
// 配置加载
config.Init() config.Init()
// 日志加载
log.Init() log.Init()
// // Mysql启动
// dao.MysqlInit()
// dao.MdbTableInit()
// // redis启动
// dao.RedisInit()
dao.Init() dao.Init()
// 队列启动
mq.Start() mq.Start()
// telegram机器人启动
go telegram.BotStart() go telegram.BotStart()
// 定时任务
go task.Start() go task.Start()
err := command.Execute()
if err != nil { if err := command.Execute(); err != nil {
panic(err) panic(err)
} }
} }
+54 -1
View File
@@ -2,10 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"github.com/spf13/viper"
"net/url" "net/url"
"os" "os"
"path/filepath"
"time" "time"
"github.com/spf13/viper"
) )
var ( var (
@@ -41,6 +43,8 @@ func Init() {
"%s%s", "%s%s",
RuntimePath, RuntimePath,
viper.GetString("log_save_path")) viper.GetString("log_save_path"))
mustMkdir(RuntimePath)
mustMkdir(LogSavePath)
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", 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_user")),
url.QueryEscape(viper.GetString("mysql_passwd")), url.QueryEscape(viper.GetString("mysql_passwd")),
@@ -54,6 +58,12 @@ func Init() {
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
} }
func mustMkdir(path string) {
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
}
func GetAppVersion() string { func GetAppVersion() string {
return "0.0.2" return "0.0.2"
} }
@@ -97,3 +107,46 @@ func GetOrderExpirationTimeDuration() time.Duration {
timer := GetOrderExpirationTime() timer := GetOrderExpirationTime()
return time.Minute * time.Duration(timer) 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
}
+1 -6
View File
@@ -5,12 +5,10 @@ go 1.25.0
require ( require (
github.com/btcsuite/btcutil v1.0.2 github.com/btcsuite/btcutil v1.0.2
github.com/dromara/carbon/v2 v2.6.15 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/go-resty/resty/v2 v2.11.0
github.com/gookit/color v1.5.0 github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6 github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1 github.com/gookit/validate v1.3.1
github.com/hibiken/asynq v0.25.1
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0 github.com/labstack/echo/v4 v4.6.0
github.com/libtnb/sqlite v1.0.1 github.com/libtnb/sqlite v1.0.1
@@ -29,12 +27,11 @@ require (
) )
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/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // 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/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.1.2 // indirect github.com/gookit/filter v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // 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/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml v1.9.4 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/afero v1.6.0 // indirect github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.10.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/sys v0.42.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.14.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/ini.v1 v1.63.2 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
-25
View File
@@ -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/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 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/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/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/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= 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/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/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 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/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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI=
github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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/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-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 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= 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= 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/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.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= 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/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-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.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.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.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/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.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= 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.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= 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.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 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 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 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= 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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/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 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/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 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo=
gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg= 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/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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+91
View File
@@ -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)
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ func Init() {
log.Fatalf("DBInit err: %v", err) log.Fatalf("DBInit err: %v", err)
} }
if err := RedisInit(); err != nil { if err := RuntimeInit(); err != nil {
log.Fatalf("RedisInit err: %v", err) log.Fatalf("RuntimeInit err: %v", err)
} }
} }
-46
View File
@@ -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
}
+66
View File
@@ -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
}
+107 -54
View File
@@ -1,109 +1,162 @@
package data package data
import ( import (
"context" "errors"
"fmt" "time"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/go-redis/redis/v8" "github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
"time" "gorm.io/gorm/clause"
) )
var ( var ErrTransactionLocked = errors.New("transaction amount is already locked")
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
)
// 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) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err return order, err
} }
// GetOrderInfoByTradeId 通过交易号查询订单 // GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err return order, err
} }
// CreateOrderWithTransaction 事务创建订单 // CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error return tx.Model(order).Create(order).Error
return err
} }
// GetOrderByBlockIdWithTransaction 通过区块获取订单 // GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
return order, err return order, err
} }
// OrderSuccessWithTransaction 事务支付成功 // OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error { func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{ result := tx.Model(&mdb.Orders{}).
"block_transaction_id": req.BlockTransactionId, Where("trade_id = ?", req.TradeId).
"status": mdb.StatusPaySuccess, Where("status = ?", mdb.StatusWaitPay).
"callback_confirm": mdb.CallBackConfirmNo, Updates(map[string]interface{}{
}).Error "block_transaction_id": req.BlockTransactionId,
return err "status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
})
return result.RowsAffected > 0, result.Error
} }
// GetPendingCallbackOrders 查询出等待回调的订单 // GetPendingCallbackOrders returns orders that still need callback delivery.
func GetPendingCallbackOrders() ([]mdb.Orders, error) { func GetPendingCallbackOrders(maxRetry int, limit int) ([]mdb.Orders, error) {
var orders []mdb.Orders var orders []mdb.Orders
err := dao.Mdb.Model(orders). query := dao.Mdb.Model(&mdb.Orders{}).
Where("callback_num < ?", 5). Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess). 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 return orders, err
} }
// SaveCallBackOrdersResp 保存订单回调结果 // SaveCallBackOrdersResp persists a callback attempt result.
func SaveCallBackOrdersResp(order *mdb.Orders) error { func SaveCallBackOrdersResp(order *mdb.Orders) error {
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{ return dao.Mdb.Model(order).
"callback_num": gorm.Expr("callback_num + ?", 1), Where("id = ?", order.ID).
"callback_confirm": order.CallBackConfirm, Where("callback_confirm = ?", mdb.CallBackConfirmNo).
}).Error Updates(map[string]interface{}{
return err "callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
} }
// UpdateOrderIsExpirationById 通过id设置订单过期 // UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
func UpdateOrderIsExpirationById(id uint64) error { func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error result := dao.Mdb.Model(mdb.Orders{}).
return err 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) { func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) {
ctx := context.Background() scaledAmount, _ := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) var lock mdb.TransactionLock
result, err := dao.Rdb.Get(ctx, cacheKey).Result() err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
if err == redis.Nil { Where("token = ?", token).
return "", nil Where("amount_scaled = ?", scaledAmount).
} Where("expires_at > ?", time.Now()).
Limit(1).
Find(&lock).Error
if err != nil { if err != nil {
return "", err 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 { func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background() scaledAmount, amountText := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) now := time.Now()
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err() lock := &mdb.TransactionLock{
return err 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 { func UnLockTransaction(token string, amount float64) error {
ctx := context.Background() scaledAmount, _ := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) return dao.RuntimeDB.Where("token = ?", token).Where("amount_scaled = ?", scaledAmount).Delete(&mdb.TransactionLock{}).Error
err := dao.Rdb.Del(ctx, cacheKey).Err() }
return err
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
} }
+11 -12
View File
@@ -9,21 +9,20 @@ const (
) )
type Orders struct { type Orders struct {
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号 TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易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"` // 区块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"` // 订单金额,保留4位小数 Amount float64 `gorm:"column:amount" json:"amount"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
Token string `gorm:"column:token" json:"token"` // 所属钱包地址 Token string `gorm:"column:token" json:"token"`
Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期 Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数 CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否 CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
BaseModel BaseModel
} }
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string { func (o *Orders) TableName() string {
return "orders" return "orders"
} }
+18
View File
@@ -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"
}
+2 -4
View File
@@ -5,14 +5,12 @@ const (
TokenStatusDisable = 2 TokenStatusDisable = 2
) )
// WalletAddress 钱包表
type WalletAddress struct { type WalletAddress struct {
Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token Token string `gorm:"column:token;uniqueIndex:wallet_address_token_uindex" json:"token"`
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用 Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel BaseModel
} }
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string { func (w *WalletAddress) TableName() string {
return "wallet_address" return "wallet_address"
} }
+55 -53
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"sync" "sync"
@@ -12,42 +13,39 @@ import (
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response" "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/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math" "github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
const ( const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额 CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额 UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额 UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100 // 最大递增次数 IncrementalMaximumNumber = 100
) )
var gCreateTransactionLock sync.Mutex var gCreateTransactionLock sync.Mutex
var gOrderProcessingLock sync.Mutex
// CreateTransaction 创建订单 // CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
payAmount := math.MustParsePrecFloat64(req.Amount, 2) payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化USDT
decimalPayAmount := decimal.NewFromFloat(payAmount) decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate) decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// Usdt是否可以满足最低支付金额
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId) exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -55,7 +53,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 { if exist.ID > 0 {
return nil, constant.OrderAlreadyExists return nil, constant.OrderAlreadyExists
} }
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress() walletAddress, err := data.GetAvailableWalletAddress()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,17 +61,20 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if len(walletAddress) <= 0 { if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress return nil, constant.NotAvailableWalletAddress
} }
tradeId := GenerateCode()
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress) availableToken, availableAmount, err := ReserveAvailableWalletAndAmount(tradeId, amount, walletAddress)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if availableToken == "" { if availableToken == "" {
return nil, constant.NotAvailableAmountErr return nil, constant.NotAvailableAmountErr
} }
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: GenerateCode(), TradeId: tradeId,
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: req.Amount,
ActualAmount: availableAmount, ActualAmount: availableAmount,
@@ -82,88 +83,90 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
NotifyUrl: req.NotifyUrl, NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
} }
err = data.CreateOrderWithTransaction(tx, order) if err = data.CreateOrderWithTransaction(tx, order); err != nil {
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount)
return nil, err return nil, err
} }
// 锁定支付池 if err = tx.Commit().Error; err != nil {
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount)
return nil, err return nil, err
} }
tx.Commit()
// 超时过期消息队列 expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{ resp := &response.CreateTransactionResponse{
TradeId: order.TradeId, TradeId: order.TradeId,
OrderId: order.OrderId, OrderId: order.OrderId,
Amount: order.Amount, Amount: order.Amount,
ActualAmount: order.ActualAmount, ActualAmount: order.ActualAmount,
Token: order.Token, Token: order.Token,
ExpirationTime: ExpirationTime, ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
} }
return resp, nil return resp, nil
} }
// OrderProcessing 成功处理订单 // OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error { func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId) exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil { if err != nil {
tx.Rollback()
return err return err
} }
if exist.ID > 0 { if exist.ID > 0 {
tx.Rollback() tx.Rollback()
return constant.OrderBlockAlreadyProcess return constant.OrderBlockAlreadyProcess
} }
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req) updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
// 解锁交易 if !updated {
err = data.UnLockTransaction(req.Token, req.Amount) tx.Rollback()
if err != nil { return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback() tx.Rollback()
return err 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 return nil
} }
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 // ReserveAvailableWalletAndAmount finds and locks a token+amount pair.
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := "" availableToken := ""
availableAmount := amount availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := "" tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress { for _, address := range walletAddress {
token := address.Token err := data.LockTransaction(address.Token, tradeId, targetAmount, config.GetOrderExpirationTimeDuration())
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) if err == nil {
if err != nil { return address.Token, nil
return "", err
} }
if result == "" { if errors.Is(err, data.ErrTransactionLocked) {
availableWallet = token continue
break
} }
return "", err
} }
return availableWallet, nil return "", nil
} }
for i := 0; i < IncrementalMaximumNumber; i++ { for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount) token, err := tryLockWalletFunc(availableAmount)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
// 拿不到可用钱包就累加金额
if token == "" { if token == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
@@ -176,15 +179,14 @@ func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.Walle
return availableToken, availableAmount, nil return availableToken, availableAmount, nil
} }
// GenerateCode 订单号生成 // GenerateCode creates a unique trade id.
func GenerateCode() string { func GenerateCode() string {
date := time.Now().Format("20060102") date := time.Now().Format("20060102")
r := rand.Intn(1000) r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
} }
// GetOrderInfoByTradeId 通过交易号获取订单 // GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId) order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
+313
View File
@@ -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)
}
}
+25 -30
View File
@@ -1,24 +1,20 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
"github.com/assimon/luuu/config"
"github.com/spf13/viper"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/request" "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/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client" "github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json" "github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/gookit/goutil/stdutil" "github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -64,7 +60,7 @@ type Data struct {
Direction int `json:"direction"` 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) { func Trc20CallBack(token string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
@@ -72,6 +68,7 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
log.Sugar.Error(err) log.Sugar.Error(err)
} }
}() }()
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli() endTime := carbon.Now().TimestampMilli()
@@ -92,18 +89,20 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
if resp.StatusCode() != http.StatusOK { if resp.StatusCode() != http.StatusOK {
panic(err) panic(err)
} }
var trc20Resp UsdtTrc20Resp var trc20Resp UsdtTrc20Resp
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp) if err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp); err != nil {
if err != nil {
panic(err) panic(err)
} }
if trc20Resp.PageSize <= 0 { if trc20Resp.PageSize <= 0 {
return return
} }
for _, transfer := range trc20Resp.Data { for _, transfer := range trc20Resp.Data {
if transfer.To != token || transfer.ContractRet != "SUCCESS" { if transfer.To != token || transfer.ContractRet != "SUCCESS" {
continue continue
} }
decimalQuant, err := decimal.NewFromString(transfer.Amount) decimalQuant, err := decimal.NewFromString(transfer.Amount)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -117,43 +116,39 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
if tradeId == "" { if tradeId == "" {
continue continue
} }
order, err := data.GetOrderInfoByTradeId(tradeId) order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
panic(err) panic(err)
} }
// 区块的确认时间必须在订单创建时间之后
createTime := order.CreatedAt.TimestampMilli() createTime := order.CreatedAt.TimestampMilli()
if transfer.BlockTimestamp < createTime { if transfer.BlockTimestamp < createTime {
panic("Orders cannot actually be matched") panic("orders cannot actually be matched")
} }
// 到这一步就完全算是支付成功了
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
Token: token, Token: token,
TradeId: tradeId, TradeId: tradeId,
Amount: amount, Amount: amount,
BlockTransactionId: transfer.Hash, BlockTransactionId: transfer.Hash,
} }
err = OrderProcessing(req) if err = OrderProcessing(req); err != nil {
if 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) 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 := ` msgTpl := `
<b>📢📢有新的交易支付成功!</b> <b>馃摙馃摙鏈夋柊鐨勪氦鏄撴敮浠樻垚鍔燂紒</b>
<pre>交易号:%s</pre> <pre>浜ゆ槗鍙凤細%s</pre>
<pre>订单号:%s</pre> <pre>璁㈠崟鍙凤細%s</pre>
<pre>请求支付金额:%f cny</pre> <pre>璇锋眰鏀粯閲戦锛?f cny</pre>
<pre>实际支付金额:%f usdt</pre> <pre>瀹為檯鏀粯閲戦锛?f usdt</pre>
<pre>钱包地址:%s</pre> <pre>閽卞寘鍦板潃锛?s</pre>
<pre>订单创建时间:%s</pre> <pre>璁㈠崟鍒涘缓鏃堕棿锛?s</pre>
<pre>支付成功时间:%s</pre> <pre>鏀粯鎴愬姛鏃堕棿锛?s</pre>
` `
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString()) msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
telegram.SendToBot(msg) telegram.SendToBot(msg)
-70
View File
@@ -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
}
-39
View File
@@ -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
}
+13 -43
View File
@@ -1,52 +1,22 @@
package mq package mq
import ( import (
"fmt" "sync"
"github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/hibiken/asynq"
"github.com/spf13/viper"
) )
var MClient *asynq.Client var (
startOnce sync.Once
callbackLimiter chan struct{}
callbackInflight sync.Map
)
func Start() { func Start() {
redis := asynq.RedisClientOpt{ startOnce.Do(func() {
Addr: fmt.Sprintf( callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
"%s:%s", go runOrderExpirationLoop()
viper.GetString("redis_host"), go runOrderCallbackLoop()
viper.GetString("redis_port")), go runTransactionLockCleanupLoop()
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)
}
} }
+213
View File
@@ -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
}
+191
View File
@@ -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")
}
+13 -11
View File
@@ -1,17 +1,18 @@
package constant package constant
var Errno = map[int]string{ var Errno = map[int]string{
400: "系统错误", 400: "system error",
401: "签名认证错误", 401: "signature verification failed",
10001: "钱包地址已存在,请勿重复添加", 10001: "wallet address already exists",
10002: "支付交易已存在,请勿重复创建", 10002: "order already exists",
10003: "无可用钱包地址,无法发起支付", 10003: "no available wallet address",
10004: "支付金额有误, 无法满足最小支付单位", 10004: "invalid payment amount",
10005: "无可用金额通道", 10005: "no available amount channel",
10006: "汇率计算错误", 10006: "rate calculation failed",
10007: "订单区块已处理", 10007: "block transaction already processed",
10008: "订单不存在", 10008: "order does not exist",
10009: "无法解析请求参数", 10009: "failed to parse request params",
10010: "order status already changed",
} }
var ( var (
@@ -26,6 +27,7 @@ var (
OrderBlockAlreadyProcess = Err(10007) OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008) OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009) ParamsMarshalErr = Err(10009)
OrderStatusConflict = Err(10010)
) )
type RspError struct { type RspError struct {