Compare commits
7 Commits
fbe9e922f8
...
171ba88a2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171ba88a2e | ||
|
|
47a7725b33 | ||
|
|
ab8f711169 | ||
|
|
946c3789b1 | ||
|
|
df7446fb8c | ||
|
|
d39840a6ed | ||
|
|
dd2f4ea6c5 |
@ -14,12 +14,14 @@ import (
|
||||
"chatapp3-golang/internal/router"
|
||||
"chatapp3-golang/internal/service/apppopup"
|
||||
"chatapp3-golang/internal/service/baishun"
|
||||
"chatapp3-golang/internal/service/binancerecharge"
|
||||
"chatapp3-golang/internal/service/gameopen"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
"chatapp3-golang/internal/service/hostcenter"
|
||||
"chatapp3-golang/internal/service/invite"
|
||||
"chatapp3-golang/internal/service/lingxian"
|
||||
"chatapp3-golang/internal/service/luckygift"
|
||||
"chatapp3-golang/internal/service/managercenter"
|
||||
"chatapp3-golang/internal/service/rechargeagency"
|
||||
"chatapp3-golang/internal/service/rechargereward"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
@ -44,6 +46,7 @@ func main() {
|
||||
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||
inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||
@ -53,11 +56,13 @@ func main() {
|
||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
||||
|
||||
baishunService.SetTaskEventReporter(taskCenterService)
|
||||
gameOpenService.SetTaskEventReporter(taskCenterService)
|
||||
@ -71,6 +76,12 @@ func main() {
|
||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start week star message consumer failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||
log.Fatalf("start task center archive worker failed: %v", err)
|
||||
}
|
||||
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||
|
||||
gameProviders := gameprovider.NewRegistry(
|
||||
@ -81,10 +92,12 @@ func main() {
|
||||
AppPopup: appPopupService,
|
||||
Invite: inviteService,
|
||||
Baishun: baishunService,
|
||||
BinanceRecharge: binanceRechargeService,
|
||||
Lingxian: lingxianService,
|
||||
GameOpen: gameOpenService,
|
||||
GameProviders: gameProviders,
|
||||
LuckyGift: luckyGiftService,
|
||||
ManagerCenter: managerCenterService,
|
||||
HostCenter: hostCenterService,
|
||||
RechargeAgency: rechargeAgencyService,
|
||||
RechargeReward: rechargeRewardService,
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"chatapp3-golang/internal/bootstrap"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
"chatapp3-golang/internal/service/registerreward"
|
||||
"chatapp3-golang/internal/service/taskcenter"
|
||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||
"chatapp3-golang/internal/service/weekstar"
|
||||
"context"
|
||||
@ -24,8 +25,10 @@ func main() {
|
||||
|
||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB)
|
||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
|
||||
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer workerCancel()
|
||||
@ -36,6 +39,12 @@ func main() {
|
||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start week star message consumer failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||
log.Fatalf("start task center archive worker failed: %v", err)
|
||||
}
|
||||
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||
|
||||
log.Printf("chatapp3-golang consumer started")
|
||||
|
||||
5
go.mod
5
go.mod
@ -18,6 +18,7 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dchest/siphash v1.2.3 // indirect
|
||||
@ -32,6 +33,7 @@ require (
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
@ -42,12 +44,15 @@ require (
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@ -49,6 +49,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
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=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
@ -94,6 +96,7 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@ -132,6 +135,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
@ -140,6 +145,7 @@ github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@ -179,11 +185,15 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
||||
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
@ -201,6 +211,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@ -214,6 +225,11 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 h1:uFfgp1A7cQaAGR6QP9DsIkoEQ67b8ewj5r1RV6XB540=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.73/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
|
||||
@ -25,6 +25,7 @@ type Config struct {
|
||||
Lingxian LingxianConfig
|
||||
GameOpen GameOpenConfig
|
||||
LuckyGift LuckyGiftConfig
|
||||
BinanceRecharge BinanceRechargeConfig
|
||||
}
|
||||
|
||||
// HTTPConfig 保存 HTTP 服务运行配置。
|
||||
@ -88,6 +89,34 @@ type TaskCenterConfig struct {
|
||||
DefaultSysOrigin string
|
||||
Timezone string
|
||||
InternalEventTTL time.Duration
|
||||
MQ TaskCenterMQConfig
|
||||
Archive TaskCenterArchiveConfig
|
||||
}
|
||||
|
||||
// TaskCenterMQConfig 保存任务中心事件 MQ 消费配置。
|
||||
type TaskCenterMQConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Namespace string
|
||||
AccessKey string
|
||||
AccessSecret string
|
||||
SecurityToken string
|
||||
ConsumerGroup string
|
||||
Topic string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// TaskCenterArchiveConfig 保存任务中心原始事件 COS 归档配置。
|
||||
type TaskCenterArchiveConfig struct {
|
||||
Enabled bool
|
||||
Bucket string
|
||||
Region string
|
||||
SecretID string
|
||||
SecretKey string
|
||||
Prefix string
|
||||
BatchSize int
|
||||
FlushSeconds int
|
||||
Workers int
|
||||
}
|
||||
|
||||
// WeekStarConfig 保存周榜模块配置。
|
||||
@ -176,6 +205,11 @@ type LuckyGiftConfig struct {
|
||||
Providers []LuckyGiftProviderConfig
|
||||
}
|
||||
|
||||
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
|
||||
type BinanceRechargeConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type LuckyGiftProviderConfig struct {
|
||||
StandardID int64 `json:"standardId"`
|
||||
URL string `json:"url"`
|
||||
@ -193,6 +227,9 @@ func Load() Config {
|
||||
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
||||
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
||||
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
|
||||
return Config{
|
||||
HTTP: HTTPConfig{
|
||||
@ -215,7 +252,7 @@ func Load() Config {
|
||||
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
|
||||
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
|
||||
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
|
||||
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"),
|
||||
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL", "LIKEI_GATEWAY_ROUTE_ORDER_URI"}, "http://127.0.0.1:2600"),
|
||||
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
|
||||
},
|
||||
Worker: WorkerConfig{
|
||||
@ -255,6 +292,28 @@ func Load() Config {
|
||||
[]string{"CHATAPP_TASK_CENTER_EVENT_TTL_HOURS", "TASK_CENTER_EVENT_TTL_HOURS"},
|
||||
72,
|
||||
)) * time.Hour,
|
||||
MQ: TaskCenterMQConfig{
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_MQ_ENABLED", "TASK_CENTER_EVENT_MQ_ENABLED"}, false),
|
||||
Endpoint: taskCenterMQEndpoint,
|
||||
Namespace: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_NAMESPACE", "TASK_CENTER_EVENT_MQ_NAMESPACE"}, ""),
|
||||
AccessKey: taskCenterMQAccessKey,
|
||||
AccessSecret: taskCenterMQAccessSecret,
|
||||
SecurityToken: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_SECURITY_TOKEN", "TASK_CENTER_EVENT_MQ_SECURITY_TOKEN"}, ""),
|
||||
ConsumerGroup: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_CONSUMER_GROUP", "TASK_CENTER_EVENT_MQ_CONSUMER_GROUP"}, "task-center-progress"),
|
||||
Topic: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TOPIC", "TASK_CENTER_EVENT_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
||||
Tag: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TAG", "TASK_CENTER_EVENT_MQ_TAG"}, "task_center_event"),
|
||||
},
|
||||
Archive: TaskCenterArchiveConfig{
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "TASK_CENTER_ARCHIVE_ENABLED"}, false),
|
||||
Bucket: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_BUCKET", "TASK_CENTER_ARCHIVE_COS_BUCKET", "LIKEI_TENCENT_COS_BUCKET"}, ""),
|
||||
Region: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_REGION", "TASK_CENTER_ARCHIVE_COS_REGION", "LIKEI_TENCENT_COS_REGION"}, ""),
|
||||
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
|
||||
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
|
||||
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
|
||||
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
|
||||
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
|
||||
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
|
||||
},
|
||||
},
|
||||
WeekStar: WeekStarConfig{
|
||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
||||
@ -371,6 +430,9 @@ func Load() Config {
|
||||
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
||||
Providers: loadLuckyGiftConfigs(),
|
||||
},
|
||||
BinanceRecharge: BinanceRechargeConfig{
|
||||
BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -81,6 +81,46 @@ func TestLoadWeekStarRocketMQExplicitDisableOverridesCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
|
||||
t.Setenv("CHATAPP_TASK_CENTER_MQ_ENABLED", "true")
|
||||
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
|
||||
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
|
||||
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
|
||||
|
||||
cfg := Load()
|
||||
if !cfg.TaskCenter.MQ.Enabled {
|
||||
t.Fatalf("expected task center mq enabled")
|
||||
}
|
||||
if got := cfg.TaskCenter.MQ.Endpoint; got != "rmq.example.com:8081" {
|
||||
t.Fatalf("task center mq endpoint = %q", got)
|
||||
}
|
||||
if got := cfg.TaskCenter.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
|
||||
t.Fatalf("task center mq topic = %q", got)
|
||||
}
|
||||
if got := cfg.TaskCenter.MQ.Tag; got != "task_center_event" {
|
||||
t.Fatalf("task center mq tag = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
|
||||
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
|
||||
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")
|
||||
t.Setenv("LIKEI_TENCENT_COS_REGION", "ap-test")
|
||||
t.Setenv("LIKEI_TENCENT_COS_SECRET_ID", "secret-id")
|
||||
t.Setenv("LIKEI_TENCENT_COS_SECRET_KEY", "secret-key")
|
||||
|
||||
cfg := Load()
|
||||
if !cfg.TaskCenter.Archive.Enabled {
|
||||
t.Fatalf("expected task center archive enabled")
|
||||
}
|
||||
if got := cfg.TaskCenter.Archive.Bucket; got != "bucket-123" {
|
||||
t.Fatalf("task center archive bucket = %q", got)
|
||||
}
|
||||
if got := cfg.TaskCenter.Archive.Prefix; got != "task-center-events/v1" {
|
||||
t.Fatalf("task center archive prefix = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) {
|
||||
t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key")
|
||||
|
||||
|
||||
@ -96,6 +96,16 @@ func (g *Gateways) GivePropsBackpack(ctx context.Context, req GivePropsBackpackR
|
||||
return g.RewardDispatch.GivePropsBackpack(ctx, req)
|
||||
}
|
||||
|
||||
// GetNobleVIPAbility 透传到贵族 VIP 能力配置接口。
|
||||
func (g *Gateways) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
||||
return g.RewardQuery.GetNobleVIPAbility(ctx, sourceID)
|
||||
}
|
||||
|
||||
// GetUserMaxNobleVIPAbility 透传到用户当前最高贵族 VIP 能力配置接口。
|
||||
func (g *Gateways) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
|
||||
return g.RewardQuery.GetUserMaxNobleVIPAbility(ctx, userID)
|
||||
}
|
||||
|
||||
// SwitchUseProps 透传到道具使用切换接口。
|
||||
func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
|
||||
return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID)
|
||||
@ -126,6 +136,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio
|
||||
return g.Profile.GetUserRegion(ctx, userID, authorization)
|
||||
}
|
||||
|
||||
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
|
||||
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
}
|
||||
|
||||
// GetUserProfileByAccount 透传到资料网关。
|
||||
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||
return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account)
|
||||
@ -146,6 +161,16 @@ func (g *Gateways) RemoveUserProfileCacheAll(ctx context.Context, userID int64)
|
||||
return g.Profile.RemoveUserProfileCacheAll(ctx, userID)
|
||||
}
|
||||
|
||||
// PunishAccount 透传到账号处罚接口。
|
||||
func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
|
||||
return g.Profile.PunishAccount(ctx, req)
|
||||
}
|
||||
|
||||
// UnblockAccount 透传到账号解封接口。
|
||||
func (g *Gateways) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||
return g.Profile.UnblockAccount(ctx, req)
|
||||
}
|
||||
|
||||
// ListCurrentWeekRoomContribution 透传到房间流水网关。
|
||||
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
|
||||
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
|
||||
@ -191,6 +216,26 @@ func (g *Gateways) PageFreightSellers(ctx context.Context, authorization string,
|
||||
return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
||||
}
|
||||
|
||||
func (g *Gateways) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||
return g.Freight.ExistsFreightBalance(ctx, userID)
|
||||
}
|
||||
|
||||
func (g *Gateways) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||
return g.Freight.ExistsFreightSeller(ctx, userID)
|
||||
}
|
||||
|
||||
func (g *Gateways) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||
return g.Freight.ChangeFreightBalance(ctx, req)
|
||||
}
|
||||
|
||||
func (g *Gateways) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||
return g.Freight.AddFreightAgentReview(ctx, req)
|
||||
}
|
||||
|
||||
func (g *Gateways) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||
return g.Order.IncreaseFreightRechargeRecord(ctx, userID, amount)
|
||||
}
|
||||
|
||||
// AuthGateway 负责认证相关接口。
|
||||
type AuthGateway struct {
|
||||
client *Client
|
||||
@ -256,6 +301,10 @@ func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, us
|
||||
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
||||
}
|
||||
|
||||
func (g *OrderGateway) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||
return g.client.IncreaseFreightRechargeRecord(ctx, userID, amount)
|
||||
}
|
||||
|
||||
// RewardDispatchGateway 负责活动奖励发放接口。
|
||||
type RewardDispatchGateway struct {
|
||||
client *Client
|
||||
@ -301,6 +350,16 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
|
||||
return g.client.GetRewardGroupDetail(ctx, groupID)
|
||||
}
|
||||
|
||||
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
|
||||
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
||||
return g.client.GetNobleVIPAbility(ctx, sourceID)
|
||||
}
|
||||
|
||||
// GetUserMaxNobleVIPAbility 查询用户当前最高贵族 VIP 能力配置。
|
||||
func (g *RewardQueryGateway) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
|
||||
return g.client.GetUserMaxNobleVIPAbility(ctx, userID)
|
||||
}
|
||||
|
||||
// ProfileGateway 负责用户资料相关接口。
|
||||
type ProfileGateway struct {
|
||||
client *Client
|
||||
@ -316,6 +375,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author
|
||||
return g.client.GetUserRegion(ctx, userID, authorization)
|
||||
}
|
||||
|
||||
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
|
||||
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
}
|
||||
|
||||
// GetUserProfileByAccount 根据账号查询用户资料。
|
||||
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||
return g.client.GetUserProfileByAccount(ctx, sysOrigin, account)
|
||||
@ -336,6 +400,16 @@ func (g *ProfileGateway) RemoveUserProfileCacheAll(ctx context.Context, userID i
|
||||
return g.client.RemoveUserProfileCacheAll(ctx, userID)
|
||||
}
|
||||
|
||||
// PunishAccount 处罚用户账号。
|
||||
func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
|
||||
return g.client.PunishAccount(ctx, req)
|
||||
}
|
||||
|
||||
// UnblockAccount 解封用户账号。
|
||||
func (g *ProfileGateway) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||
return g.client.UnblockAccount(ctx, req)
|
||||
}
|
||||
|
||||
// RoomGateway 负责房间资料和房间流水相关接口。
|
||||
type RoomGateway struct {
|
||||
client *Client
|
||||
@ -400,3 +474,19 @@ type FreightGateway struct {
|
||||
func (g *FreightGateway) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
|
||||
return g.client.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
||||
}
|
||||
|
||||
func (g *FreightGateway) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||
return g.client.ExistsFreightBalance(ctx, userID)
|
||||
}
|
||||
|
||||
func (g *FreightGateway) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||
return g.client.ExistsFreightSeller(ctx, userID)
|
||||
}
|
||||
|
||||
func (g *FreightGateway) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||
return g.client.ChangeFreightBalance(ctx, req)
|
||||
}
|
||||
|
||||
func (g *FreightGateway) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||
return g.client.AddFreightAgentReview(ctx, req)
|
||||
}
|
||||
|
||||
@ -12,14 +12,17 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg config.Config
|
||||
httpClient *http.Client
|
||||
cfg config.Config
|
||||
httpClient *http.Client
|
||||
regionConfigCacheMu sync.Mutex
|
||||
regionConfigCacheMap map[string]regionConfigCacheEntry
|
||||
}
|
||||
|
||||
type UserCredential struct {
|
||||
@ -39,15 +42,16 @@ type ConsoleAccount struct {
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
ID Int64Value `json:"id"`
|
||||
Account string `json:"account"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
CountryID Int64Value `json:"countryId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
CountryName string `json:"countryName"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
OriginSys string `json:"originSys"`
|
||||
ID Int64Value `json:"id"`
|
||||
Account string `json:"account"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
CountryID Int64Value `json:"countryId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
CountryName string `json:"countryName"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
OriginSys string `json:"originSys"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
}
|
||||
|
||||
type UserRegion struct {
|
||||
@ -55,6 +59,21 @@ type UserRegion struct {
|
||||
RegionCode string `json:"regionCode"`
|
||||
}
|
||||
|
||||
type RegionConfig struct {
|
||||
RegionCode string `json:"regionCode"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RegionName string `json:"regionName"`
|
||||
CountryCodes string `json:"countryCodes"`
|
||||
Del bool `json:"del"`
|
||||
}
|
||||
|
||||
type regionConfigCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
items []RegionConfig
|
||||
}
|
||||
|
||||
const regionConfigCacheTTL = time.Minute
|
||||
|
||||
type RoomContributionActivityCount struct {
|
||||
ID string `json:"id"`
|
||||
RoomID Int64Value `json:"roomId"`
|
||||
@ -141,6 +160,31 @@ type GivePropsBackpackRequest struct {
|
||||
OpUser int64 `json:"opUser,omitempty"`
|
||||
}
|
||||
|
||||
type PropsNobleVIPAbility struct {
|
||||
ID Int64Value `json:"id"`
|
||||
VIPType string `json:"vipType"`
|
||||
VIPLevel int `json:"vipLevel"`
|
||||
AdminNumber int `json:"adminNumber"`
|
||||
RoomMaxMember int `json:"roomMaxMember"`
|
||||
AvatarFrameID Int64Value `json:"avatarFrameId"`
|
||||
CarID Int64Value `json:"carId"`
|
||||
ChatBubbleID Int64Value `json:"chatBubbleId"`
|
||||
DataCardID Int64Value `json:"dataCardId"`
|
||||
}
|
||||
|
||||
type PunishAccountRequest struct {
|
||||
BeOptUserID int64 `json:"beOptUserId"`
|
||||
OptUserID int64 `json:"optUserId"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type UnblockAccountRequest struct {
|
||||
BeOptUserID int64 `json:"beOptUserId"`
|
||||
OptUserID int64 `json:"optUserId"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type SendActivityRewardRequest struct {
|
||||
TrackID int64 `json:"trackId"`
|
||||
Origin string `json:"origin"`
|
||||
@ -258,6 +302,25 @@ type FreightSellerProfile struct {
|
||||
OriginSys string `json:"originSys"`
|
||||
}
|
||||
|
||||
type FreightBalanceChangeRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID int64 `json:"userId"`
|
||||
AcceptUserID int64 `json:"acceptUserId"`
|
||||
Type string `json:"type"`
|
||||
AcceptAmount string `json:"acceptAmount"`
|
||||
OrderAmount string `json:"orderAmount"`
|
||||
USDAmount string `json:"usdAmount"`
|
||||
Origin string `json:"origin"`
|
||||
Remark string `json:"remark"`
|
||||
RechargeType string `json:"rechargeType"`
|
||||
CreateUser int64 `json:"createUser"`
|
||||
}
|
||||
|
||||
type FreightAgentReviewRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
|
||||
type freightSellerPageResponse struct {
|
||||
Status *bool `json:"status"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
@ -370,6 +433,7 @@ func New(cfg config.Config) *Client {
|
||||
httpClient: &http.Client{
|
||||
Timeout: cfg.HTTP.Timeout,
|
||||
},
|
||||
regionConfigCacheMap: map[string]regionConfigCacheEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -576,6 +640,26 @@ func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackReq
|
||||
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props/client/giveProps", req, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getAbilityDTO?sourceId=" +
|
||||
url.QueryEscape(strconv.FormatInt(sourceID, 10))
|
||||
var resp resultResponse[PropsNobleVIPAbility]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return PropsNobleVIPAbility{}, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getUserMaxAbilityDTO?userId=" +
|
||||
url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||
var resp resultResponse[PropsNobleVIPAbility]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return PropsNobleVIPAbility{}, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" +
|
||||
url.QueryEscape(strconv.FormatInt(userID, 10)) +
|
||||
@ -687,6 +771,94 @@ func (c *Client) PageFreightSellers(ctx context.Context, authorization string, s
|
||||
return resp.FreightSellerPage, nil
|
||||
}
|
||||
|
||||
func (c *Client) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsBalance?" + values.Encode()
|
||||
var resp resultResponse[bool]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
return false, errors.New(strings.TrimSpace(resp.Message))
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsSeller?" + values.Encode()
|
||||
var resp resultResponse[bool]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
return false, errors.New(strings.TrimSpace(resp.Message))
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/changeBalance"
|
||||
var resp resultResponse[any]
|
||||
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
message := strings.TrimSpace(resp.Message)
|
||||
if message == "" {
|
||||
message = "freight balance change failed"
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/addFreightAgentReview"
|
||||
var resp resultResponse[any]
|
||||
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
message := strings.TrimSpace(resp.Message)
|
||||
if message == "" {
|
||||
message = "freight agent review add failed"
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("userId is required")
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||
values.Set("amount", strings.TrimSpace(amount))
|
||||
endpoint := strings.TrimRight(c.cfg.Java.OrderBaseURL, "/") + "/user/freight/recharge-record/client/inrNowMonthAmount?" + values.Encode()
|
||||
var resp resultResponse[DecimalString]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
message := strings.TrimSpace(resp.Message)
|
||||
if message == "" {
|
||||
message = "freight recharge record increase failed"
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||
var resp resultResponse[UserProfile]
|
||||
@ -715,6 +887,75 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
if countryCode == "" {
|
||||
return "", nil
|
||||
}
|
||||
configs, err := c.ListRegionConfigs(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, item := range configs {
|
||||
if item.Del {
|
||||
continue
|
||||
}
|
||||
if !regionConfigContainsCountryCode(item.CountryCodes, countryCode) {
|
||||
continue
|
||||
}
|
||||
regionCode := strings.TrimSpace(item.RegionCode)
|
||||
if regionCode != "" {
|
||||
return regionCode, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRegionConfigs(ctx context.Context, sysOrigin string) ([]RegionConfig, error) {
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
if sysOrigin == "" {
|
||||
sysOrigin = "LIKEI"
|
||||
}
|
||||
now := time.Now()
|
||||
c.regionConfigCacheMu.Lock()
|
||||
if entry, ok := c.regionConfigCacheMap[sysOrigin]; ok && now.Before(entry.expiresAt) {
|
||||
items := append([]RegionConfig(nil), entry.items...)
|
||||
c.regionConfigCacheMu.Unlock()
|
||||
return items, nil
|
||||
}
|
||||
c.regionConfigCacheMu.Unlock()
|
||||
|
||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/region-config/client/listByCondition"
|
||||
var resp resultResponse[[]RegionConfig]
|
||||
if err := c.postJSON(ctx, endpoint, map[string]string{"sysOrigin": sysOrigin}, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := append([]RegionConfig(nil), resp.Body...)
|
||||
c.regionConfigCacheMu.Lock()
|
||||
if c.regionConfigCacheMap == nil {
|
||||
c.regionConfigCacheMap = map[string]regionConfigCacheEntry{}
|
||||
}
|
||||
c.regionConfigCacheMap[sysOrigin] = regionConfigCacheEntry{
|
||||
expiresAt: now.Add(regionConfigCacheTTL),
|
||||
items: append([]RegionConfig(nil), items...),
|
||||
}
|
||||
c.regionConfigCacheMu.Unlock()
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func regionConfigContainsCountryCode(countryCodes string, countryCode string) bool {
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
if countryCode == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Split(countryCodes, ",") {
|
||||
if strings.ToUpper(strings.TrimSpace(item)) == countryCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" +
|
||||
url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account))
|
||||
@ -838,6 +1079,14 @@ func (c *Client) RemoveUserProfileCacheAll(ctx context.Context, userID int64) er
|
||||
return c.getJSON(ctx, endpoint, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
|
||||
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/punishment", req, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/unblock", req, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
|
||||
var resp resultResponse[map[string]int64]
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
)
|
||||
|
||||
func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
|
||||
@ -29,3 +35,45 @@ func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
|
||||
t.Fatalf("Amount = %q, want 1.60", item.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/region-config/client/listByCondition" {
|
||||
t.Fatalf("request = %s %s, want POST /region-config/client/listByCondition", r.Method, r.URL.Path)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if payload["sysOrigin"] != "LIKEI" {
|
||||
t.Fatalf("sysOrigin = %q, want LIKEI", payload["sysOrigin"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"body":[{"regionCode":"中东区","sysOrigin":"LIKEI","countryCodes":"JO, EG, KSA","del":false},{"regionCode":"US","sysOrigin":"LIKEI","countryCodes":"US","del":true}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(config.Config{
|
||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
||||
})
|
||||
got, err := client.ResolveRegionCodeByCountryCode(context.Background(), " likei ", " ksa ")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRegionCodeByCountryCode() error = %v", err)
|
||||
}
|
||||
if got != "中东区" {
|
||||
t.Fatalf("region = %q, want 中东区", got)
|
||||
}
|
||||
got, err = client.ResolveRegionCodeByCountryCode(context.Background(), "LIKEI", "eg")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRegionCodeByCountryCode() cached error = %v", err)
|
||||
}
|
||||
if got != "中东区" {
|
||||
t.Fatalf("cached region = %q, want 中东区", got)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("region config calls = %d, want cached 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
60
internal/model/binance_recharge_models.go
Normal file
60
internal/model/binance_recharge_models.go
Normal file
@ -0,0 +1,60 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// BinanceRechargeConfig stores Binance API credentials per app origin.
|
||||
type BinanceRechargeConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_binance_recharge_config_origin"`
|
||||
APIKey string `gorm:"column:api_key;size:255"`
|
||||
APISecret string `gorm:"column:api_secret;size:255"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (BinanceRechargeConfig) TableName() string {
|
||||
return "binance_recharge_config"
|
||||
}
|
||||
|
||||
// BinanceRechargeRate maps a verified USDT amount range to gold per USD.
|
||||
type BinanceRechargeRate struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
ConfigID int64 `gorm:"column:config_id;index:idx_binance_recharge_rate_config"`
|
||||
MinAmount string `gorm:"column:min_amount;type:decimal(20,8)"`
|
||||
MaxAmount string `gorm:"column:max_amount;type:decimal(20,8)"`
|
||||
GoldPerUSD string `gorm:"column:gold_per_usd;type:decimal(20,8)"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (BinanceRechargeRate) TableName() string {
|
||||
return "binance_recharge_rate"
|
||||
}
|
||||
|
||||
// BinanceRechargeVerifyRecord is the local idempotency ledger for auto recharge verification.
|
||||
type BinanceRechargeVerifyRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_binance_recharge_verify_origin;uniqueIndex:uk_binance_recharge_verify_order,priority:1;uniqueIndex:uk_binance_recharge_verify_bill,priority:1"`
|
||||
DealerUserID int64 `gorm:"column:dealer_user_id;index:idx_binance_recharge_verify_dealer"`
|
||||
TargetUserID int64 `gorm:"column:target_user_id;index:idx_binance_recharge_verify_target"`
|
||||
OrderNo string `gorm:"column:order_no;size:128;uniqueIndex:uk_binance_recharge_verify_order,priority:2"`
|
||||
TransferType string `gorm:"column:transfer_type;size:32;uniqueIndex:uk_binance_recharge_verify_bill,priority:2"`
|
||||
BillNo string `gorm:"column:bill_no;size:160;uniqueIndex:uk_binance_recharge_verify_bill,priority:3"`
|
||||
AmountUSDT string `gorm:"column:amount_usdt;type:decimal(20,8)"`
|
||||
GoldAmount int64 `gorm:"column:gold_amount"`
|
||||
RateGoldPerUSD string `gorm:"column:rate_gold_per_usd;type:decimal(20,8)"`
|
||||
BinanceTransactionID string `gorm:"column:binance_transaction_id;size:180"`
|
||||
BinanceOrderType string `gorm:"column:binance_order_type;size:64"`
|
||||
BinanceRawJSON string `gorm:"column:binance_raw_json;type:text"`
|
||||
Status string `gorm:"column:status;size:24;index:idx_binance_recharge_verify_status"`
|
||||
FailReason string `gorm:"column:fail_reason;size:500"`
|
||||
Remark string `gorm:"column:remark;size:500"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (BinanceRechargeVerifyRecord) TableName() string {
|
||||
return "binance_recharge_verify_record"
|
||||
}
|
||||
@ -99,6 +99,43 @@ type TaskCenterEvent struct {
|
||||
|
||||
func (TaskCenterEvent) TableName() string { return "task_center_event" }
|
||||
|
||||
// TaskCenterEventDedup 保存业务事件幂等记录。
|
||||
type TaskCenterEventDedup struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_event_dedup,priority:1"`
|
||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_event_dedup,priority:2"`
|
||||
EventType string `gorm:"column:event_type;size:64"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_task_center_event_dedup_status"`
|
||||
FailureReason string `gorm:"column:failure_reason;size:512"`
|
||||
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_event_dedup_create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup" }
|
||||
|
||||
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
||||
type TaskCenterEventArchiveOutbox struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
|
||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
||||
EventType string `gorm:"column:event_type;size:64"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
OccurredAt time.Time `gorm:"column:occurred_at"`
|
||||
RawJSON string `gorm:"column:raw_json;type:text"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1"`
|
||||
RetryCount int `gorm:"column:retry_count"`
|
||||
COSKey string `gorm:"column:cos_key;size:512"`
|
||||
FailureReason string `gorm:"column:failure_reason;size:512"`
|
||||
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
ArchivedAt *time.Time `gorm:"column:archived_at"`
|
||||
}
|
||||
|
||||
func (TaskCenterEventArchiveOutbox) TableName() string {
|
||||
return "task_center_event_archive_outbox"
|
||||
}
|
||||
|
||||
// TaskCenterClaimRecord 保存任务奖励领取记录。
|
||||
type TaskCenterClaimRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
|
||||
56
internal/router/binance_recharge_routes.go
Normal file
56
internal/router/binance_recharge_routes.go
Normal file
@ -0,0 +1,56 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"chatapp3-golang/internal/service/binancerecharge"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// registerBinanceRechargeRoutes registers H5 verification and admin config routes.
|
||||
func registerBinanceRechargeRoutes(engine *gin.Engine, javaClient authGateway, service *binancerecharge.Service) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
appGroup := engine.Group("/app/h5/binance-recharge")
|
||||
appGroup.Use(authMiddleware(javaClient))
|
||||
appGroup.POST("/verify", func(c *gin.Context) {
|
||||
var req binancerecharge.VerifyRechargeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, binancerecharge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.VerifyAndRecharge(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
adminGroup := engine.Group("/payment/binance")
|
||||
adminGroup.Use(consoleAuthMiddleware(javaClient))
|
||||
adminGroup.GET("/config", func(c *gin.Context) {
|
||||
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
adminGroup.POST("/config/save", func(c *gin.Context) {
|
||||
var req binancerecharge.SaveConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, binancerecharge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.SaveConfig(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
90
internal/router/manager_center_routes.go
Normal file
90
internal/router/manager_center_routes.go
Normal file
@ -0,0 +1,90 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/service/managercenter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// registerManagerCenterRoutes 注册经理中心 H5 路由。
|
||||
func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, service *managercenter.Service) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
group := engine.Group("/app/h5/manager-center")
|
||||
group.Use(authMiddleware(javaClient))
|
||||
|
||||
group.GET("/profile", func(c *gin.Context) {
|
||||
resp, err := service.GetProfile(c.Request.Context(), mustAuthUser(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.GET("/users/search", func(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Query("account"))
|
||||
resp, err := service.SearchUser(c.Request.Context(), mustAuthUser(c), account)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.GET("/props", func(c *gin.Context) {
|
||||
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/props/send", func(c *gin.Context) {
|
||||
var req managercenter.SendPropsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.SendProps(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/users/ban", func(c *gin.Context) {
|
||||
var req managercenter.BanUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.BanUser(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/users/unban", func(c *gin.Context) {
|
||||
var req managercenter.UnbanUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.UnbanUser(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
@ -8,12 +8,14 @@ import (
|
||||
"chatapp3-golang/internal/repo"
|
||||
"chatapp3-golang/internal/service/apppopup"
|
||||
"chatapp3-golang/internal/service/baishun"
|
||||
"chatapp3-golang/internal/service/binancerecharge"
|
||||
"chatapp3-golang/internal/service/gameopen"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
"chatapp3-golang/internal/service/hostcenter"
|
||||
"chatapp3-golang/internal/service/invite"
|
||||
"chatapp3-golang/internal/service/lingxian"
|
||||
"chatapp3-golang/internal/service/luckygift"
|
||||
"chatapp3-golang/internal/service/managercenter"
|
||||
"chatapp3-golang/internal/service/rechargeagency"
|
||||
"chatapp3-golang/internal/service/rechargereward"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
@ -33,10 +35,12 @@ type Services struct {
|
||||
AppPopup *apppopup.Service
|
||||
Invite *invite.InviteService
|
||||
Baishun *baishun.BaishunService
|
||||
BinanceRecharge *binancerecharge.Service
|
||||
Lingxian *lingxian.Service
|
||||
GameOpen *gameopen.GameOpenService
|
||||
GameProviders *gameprovider.Registry
|
||||
LuckyGift *luckygift.LuckyGiftService
|
||||
ManagerCenter *managercenter.Service
|
||||
HostCenter *hostcenter.Service
|
||||
RechargeAgency *rechargeagency.Service
|
||||
RechargeReward *rechargereward.Service
|
||||
@ -65,6 +69,7 @@ func NewRouter(
|
||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
||||
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
|
||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||
@ -79,6 +84,7 @@ func NewRouter(
|
||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
@ -130,21 +130,4 @@ func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
adminGroup.GET("/event/page", func(c *gin.Context) {
|
||||
resp, err := service.PageEvents(
|
||||
c.Request.Context(),
|
||||
c.Query("sysOrigin"),
|
||||
c.Query("taskCategory"),
|
||||
taskcenter.ParseInt64(c.Query("userId")),
|
||||
c.Query("taskCode"),
|
||||
c.Query("eventType"),
|
||||
int(taskcenter.ParseInt64(c.Query("cursor"))),
|
||||
int(taskcenter.ParseInt64(c.Query("limit"))),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
78
internal/service/binancerecharge/amount.go
Normal file
78
internal/service/binancerecharge/amount.go
Normal file
@ -0,0 +1,78 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parsePositiveDecimal(value string, field string) (*big.Rat, string, error) {
|
||||
normalized, err := normalizeDecimal(value)
|
||||
if err != nil {
|
||||
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
|
||||
}
|
||||
rat, ok := new(big.Rat).SetString(normalized)
|
||||
if !ok || rat.Sign() <= 0 {
|
||||
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than 0")
|
||||
}
|
||||
return rat, normalized, nil
|
||||
}
|
||||
|
||||
func parseNonNegativeDecimal(value string, field string) (*big.Rat, string, error) {
|
||||
normalized, err := normalizeDecimal(value)
|
||||
if err != nil {
|
||||
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
|
||||
}
|
||||
rat, ok := new(big.Rat).SetString(normalized)
|
||||
if !ok || rat.Sign() < 0 {
|
||||
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than or equal to 0")
|
||||
}
|
||||
return rat, normalized, nil
|
||||
}
|
||||
|
||||
func normalizeDecimal(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("blank decimal")
|
||||
}
|
||||
rat, ok := new(big.Rat).SetString(value)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid decimal")
|
||||
}
|
||||
return trimDecimalZeros(rat.FloatString(8)), nil
|
||||
}
|
||||
|
||||
func trimDecimalZeros(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.Contains(value, ".") {
|
||||
return value
|
||||
}
|
||||
value = strings.TrimRight(value, "0")
|
||||
value = strings.TrimRight(value, ".")
|
||||
if value == "" || value == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func decimalEqual(left string, right string) bool {
|
||||
leftRat, ok := new(big.Rat).SetString(strings.TrimSpace(left))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
rightRat, ok := new(big.Rat).SetString(strings.TrimSpace(right))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return leftRat.Cmp(rightRat) == 0
|
||||
}
|
||||
|
||||
func calculateGold(amount *big.Rat, rate *big.Rat) (int64, error) {
|
||||
product := new(big.Rat).Mul(amount, rate)
|
||||
gold := new(big.Int).Quo(product.Num(), product.Denom())
|
||||
if !gold.IsInt64() {
|
||||
return 0, NewAppError(http.StatusBadRequest, "gold_amount_too_large", "gold amount is too large")
|
||||
}
|
||||
return gold.Int64(), nil
|
||||
}
|
||||
282
internal/service/binancerecharge/binance.go
Normal file
282
internal/service/binancerecharge/binance.go
Normal file
@ -0,0 +1,282 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBinanceBaseURL = "https://api.binance.com"
|
||||
binanceUSDT = "USDT"
|
||||
)
|
||||
|
||||
type HTTPBinanceVerifier struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPBinanceVerifier(baseURL string, timeout time.Duration) *HTTPBinanceVerifier {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBinanceBaseURL
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &HTTPBinanceVerifier{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *HTTPBinanceVerifier) Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||
if v == nil {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
|
||||
}
|
||||
switch query.TransferType {
|
||||
case TransferTypeBinancePay:
|
||||
return v.verifyPayTransaction(ctx, credential, query)
|
||||
case TransferTypeChain:
|
||||
return v.verifyChainDeposit(ctx, credential, query)
|
||||
default:
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
|
||||
}
|
||||
}
|
||||
|
||||
func (v *HTTPBinanceVerifier) verifyPayTransaction(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||
now := time.Now()
|
||||
values := url.Values{}
|
||||
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
|
||||
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
|
||||
values.Set("limit", "100")
|
||||
values.Set("recvWindow", "5000")
|
||||
|
||||
raw, err := v.signedGet(ctx, credential, "/sapi/v1/pay/transactions", values)
|
||||
if err != nil {
|
||||
return VerifiedTransaction{}, err
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Success *bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
|
||||
}
|
||||
if resp.Success != nil && !*resp.Success {
|
||||
message := strings.TrimSpace(resp.Message)
|
||||
if message == "" {
|
||||
message = "binance pay query failed"
|
||||
}
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_query_failed", message)
|
||||
}
|
||||
|
||||
foundBill := false
|
||||
for _, item := range resp.Data {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(item, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if !binancePayRecordMatchesBill(record, query.BillNo) {
|
||||
continue
|
||||
}
|
||||
foundBill = true
|
||||
if !binancePayRecordMatchesAmount(record, query.Amount) {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
|
||||
}
|
||||
return VerifiedTransaction{
|
||||
TransactionID: firstString(record, "transactionId", "orderId", "merchantTradeNo"),
|
||||
OrderType: firstString(record, "orderType"),
|
||||
Amount: query.Amount,
|
||||
RawJSON: string(item),
|
||||
}, nil
|
||||
}
|
||||
if foundBill {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
|
||||
}
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance transaction was not found")
|
||||
}
|
||||
|
||||
func (v *HTTPBinanceVerifier) verifyChainDeposit(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||
now := time.Now()
|
||||
values := url.Values{}
|
||||
values.Set("coin", binanceUSDT)
|
||||
values.Set("status", "1")
|
||||
values.Set("txId", query.BillNo)
|
||||
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
|
||||
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
|
||||
values.Set("limit", "1000")
|
||||
values.Set("recvWindow", "5000")
|
||||
|
||||
raw, err := v.signedGet(ctx, credential, "/sapi/v1/capital/deposit/hisrec", values)
|
||||
if err != nil {
|
||||
return VerifiedTransaction{}, err
|
||||
}
|
||||
|
||||
var rows []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
|
||||
}
|
||||
|
||||
foundBill := false
|
||||
for _, item := range rows {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(item, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if !chainDepositRecordMatchesBill(record, query.BillNo) {
|
||||
continue
|
||||
}
|
||||
foundBill = true
|
||||
if !strings.EqualFold(firstString(record, "coin"), binanceUSDT) {
|
||||
continue
|
||||
}
|
||||
if !chainDepositStatusSuccess(record) {
|
||||
continue
|
||||
}
|
||||
if !decimalEqual(firstString(record, "amount"), query.Amount) {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
|
||||
}
|
||||
return VerifiedTransaction{
|
||||
TransactionID: firstString(record, "txId", "id"),
|
||||
OrderType: "CHAIN_DEPOSIT",
|
||||
Amount: query.Amount,
|
||||
RawJSON: string(item),
|
||||
}, nil
|
||||
}
|
||||
if foundBill {
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
|
||||
}
|
||||
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance deposit was not found")
|
||||
}
|
||||
|
||||
func (v *HTTPBinanceVerifier) signedGet(ctx context.Context, credential BinanceCredential, path string, values url.Values) ([]byte, error) {
|
||||
apiKey := strings.TrimSpace(credential.APIKey)
|
||||
apiSecret := strings.TrimSpace(credential.APISecret)
|
||||
if apiKey == "" || apiSecret == "" {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "binance_credentials_missing", "binance api key and secret are required")
|
||||
}
|
||||
values.Set("timestamp", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||
query := values.Encode()
|
||||
signature := signBinanceQuery(apiSecret, query)
|
||||
|
||||
endpoint := v.baseURL + path + "?" + query + "&signature=" + url.QueryEscape(signature)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
|
||||
}
|
||||
req.Header.Set("X-MBX-APIKEY", apiKey)
|
||||
|
||||
resp, err := v.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "binance_response_read_failed", err.Error())
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, NewAppError(http.StatusBadGateway, "binance_query_failed", strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func signBinanceQuery(secret string, query string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(query))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func binancePayRecordMatchesBill(record map[string]any, billNo string) bool {
|
||||
billNo = strings.TrimSpace(billNo)
|
||||
for _, key := range []string{"transactionId", "orderId", "merchantTradeNo", "prepayId", "id"} {
|
||||
if strings.EqualFold(firstString(record, key), billNo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func binancePayRecordMatchesAmount(record map[string]any, expected string) bool {
|
||||
if strings.EqualFold(firstString(record, "currency"), binanceUSDT) && decimalEqual(firstString(record, "amount"), expected) {
|
||||
return true
|
||||
}
|
||||
details, ok := record["fundsDetail"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range details {
|
||||
detail, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(firstString(detail, "currency"), binanceUSDT) && decimalEqual(firstString(detail, "amount"), expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chainDepositRecordMatchesBill(record map[string]any, billNo string) bool {
|
||||
billNo = strings.TrimSpace(billNo)
|
||||
for _, key := range []string{"txId", "id"} {
|
||||
if strings.EqualFold(firstString(record, key), billNo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chainDepositStatusSuccess(record map[string]any) bool {
|
||||
switch value := record["status"].(type) {
|
||||
case float64:
|
||||
return int(value) == 1
|
||||
case int:
|
||||
return value == 1
|
||||
case string:
|
||||
return strings.TrimSpace(value) == "1"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(record map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := stringValue(record[key]); strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return trimDecimalZeros(fmt.Sprintf("%.8f", typed))
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
220
internal/service/binancerecharge/config.go
Normal file
220
internal/service/binancerecharge/config.go
Normal file
@ -0,0 +1,220 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
if sysOrigin == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
var cfg model.BinanceRechargeConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
First(&cfg).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ConfigResponse{
|
||||
SysOrigin: sysOrigin,
|
||||
Enabled: true,
|
||||
Rates: []RateConfigResponse{},
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, NewAppError(http.StatusInternalServerError, "config_query_failed", err.Error())
|
||||
}
|
||||
|
||||
rates, err := s.listRates(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ConfigResponse{
|
||||
SysOrigin: cfg.SysOrigin,
|
||||
APIKey: cfg.APIKey,
|
||||
APISecretConfigured: strings.TrimSpace(cfg.APISecret) != "",
|
||||
Enabled: cfg.Enabled,
|
||||
Rates: rates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
|
||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||
if sysOrigin == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
apiSecret := strings.TrimSpace(req.APISecret)
|
||||
rates, err := validateRateRequests(req.Rates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg model.BinanceRechargeConfig
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
var existing model.BinanceRechargeConfig
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
First(&existing).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if apiSecret == "" && existing.ID > 0 {
|
||||
apiSecret = existing.APISecret
|
||||
}
|
||||
if apiKey == "" {
|
||||
return NewAppError(http.StatusBadRequest, "api_key_required", "apiKey is required")
|
||||
}
|
||||
if apiSecret == "" {
|
||||
return NewAppError(http.StatusBadRequest, "api_secret_required", "apiSecret is required")
|
||||
}
|
||||
|
||||
cfg = model.BinanceRechargeConfig{
|
||||
ID: existing.ID,
|
||||
SysOrigin: sysOrigin,
|
||||
APIKey: apiKey,
|
||||
APISecret: apiSecret,
|
||||
Enabled: req.Enabled,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
cfg.CreateTime = existing.CreateTime
|
||||
if err := tx.Model(&model.BinanceRechargeConfig{}).
|
||||
Where("id = ?", existing.ID).
|
||||
Updates(map[string]any{
|
||||
"api_key": apiKey,
|
||||
"api_secret": apiSecret,
|
||||
"enabled": req.Enabled,
|
||||
"update_time": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Create(&cfg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("config_id = ?", cfg.ID).Delete(&model.BinanceRechargeRate{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]model.BinanceRechargeRate, 0, len(rates))
|
||||
for index, item := range rates {
|
||||
rows = append(rows, model.BinanceRechargeRate{
|
||||
ConfigID: cfg.ID,
|
||||
MinAmount: item.MinAmount,
|
||||
MaxAmount: item.MaxAmount,
|
||||
GoldPerUSD: item.GoldPerUSD,
|
||||
SortOrder: index + 1,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
})
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, NewAppError(http.StatusInternalServerError, "config_save_failed", err.Error())
|
||||
}
|
||||
|
||||
return s.GetConfig(ctx, sysOrigin)
|
||||
}
|
||||
|
||||
func (s *Service) listRates(ctx context.Context, configID int64) ([]RateConfigResponse, error) {
|
||||
var rows []model.BinanceRechargeRate
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("config_id = ?", configID).
|
||||
Order("sort_order asc, min_amount asc, id asc").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, NewAppError(http.StatusInternalServerError, "rate_query_failed", err.Error())
|
||||
}
|
||||
resp := make([]RateConfigResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
resp = append(resp, RateConfigResponse{
|
||||
ID: row.ID,
|
||||
MinAmount: trimDecimalZeros(row.MinAmount),
|
||||
MaxAmount: trimDecimalZeros(row.MaxAmount),
|
||||
GoldPerUSD: trimDecimalZeros(row.GoldPerUSD),
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateRateRequests(input []RateConfigRequest) ([]RateConfigResponse, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "rates_required", "at least one rate range is required")
|
||||
}
|
||||
rates := make([]RateConfigResponse, 0, len(input))
|
||||
for _, item := range input {
|
||||
minRat, minAmount, err := parseNonNegativeDecimal(item.MinAmount, "minAmount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxRat, maxAmount, err := parseNonNegativeDecimal(defaultZero(item.MaxAmount), "maxAmount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, goldPerUSD, err := parsePositiveDecimal(item.GoldPerUSD, "goldPerUsd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxRat.Sign() > 0 && maxRat.Cmp(minRat) <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "maxAmount must be greater than minAmount")
|
||||
}
|
||||
rates = append(rates, RateConfigResponse{
|
||||
MinAmount: minAmount,
|
||||
MaxAmount: maxAmount,
|
||||
GoldPerUSD: goldPerUSD,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(rates, func(i, j int) bool {
|
||||
left, _ := new(big.Rat).SetString(rates[i].MinAmount)
|
||||
right, _ := new(big.Rat).SetString(rates[j].MinAmount)
|
||||
return left.Cmp(right) < 0
|
||||
})
|
||||
for i := 0; i < len(rates); i++ {
|
||||
currentMax, _ := new(big.Rat).SetString(rates[i].MaxAmount)
|
||||
if currentMax.Sign() == 0 {
|
||||
if i != len(rates)-1 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "open-ended rate range must be the last range")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if i+1 < len(rates) {
|
||||
nextMin, _ := new(big.Rat).SetString(rates[i+1].MinAmount)
|
||||
if currentMax.Cmp(nextMin) > 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "rate ranges cannot overlap")
|
||||
}
|
||||
}
|
||||
}
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
func defaultZero(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "0"
|
||||
}
|
||||
return value
|
||||
}
|
||||
201
internal/service/binancerecharge/service_test.go
Normal file
201
internal/service/binancerecharge/service_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSaveConfigKeepsExistingSecretWhenBlank(t *testing.T) {
|
||||
db := newBinanceRechargeTestDB(t)
|
||||
service := NewService(config.Config{}, db, &fakeBinanceJavaGateway{})
|
||||
|
||||
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
APIKey: "key-1",
|
||||
APISecret: "secret-1",
|
||||
Enabled: true,
|
||||
Rates: []RateConfigRequest{
|
||||
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("initial save config: %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
APIKey: "key-2",
|
||||
Enabled: true,
|
||||
Rates: []RateConfigRequest{
|
||||
{MinAmount: "0", MaxAmount: "0", GoldPerUSD: "82000"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
if resp.APIKey != "key-2" {
|
||||
t.Fatalf("APIKey = %q, want key-2", resp.APIKey)
|
||||
}
|
||||
if !resp.APISecretConfigured {
|
||||
t.Fatalf("APISecretConfigured = false, want true")
|
||||
}
|
||||
|
||||
var row model.BinanceRechargeConfig
|
||||
if err := db.Where("sys_origin = ?", "LIKEI").First(&row).Error; err != nil {
|
||||
t.Fatalf("query config: %v", err)
|
||||
}
|
||||
if row.APISecret != "secret-1" {
|
||||
t.Fatalf("APISecret = %q, want secret-1", row.APISecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAndRechargeIsIdempotent(t *testing.T) {
|
||||
db := newBinanceRechargeTestDB(t)
|
||||
java := &fakeBinanceJavaGateway{
|
||||
profile: integration.UserProfile{
|
||||
ID: integration.Int64Value(200),
|
||||
OriginSys: "LIKEI",
|
||||
},
|
||||
balanceExists: true,
|
||||
}
|
||||
service := NewService(config.Config{}, db, java)
|
||||
service.SetBinanceVerifier(&fakeBinanceVerifier{})
|
||||
|
||||
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
APIKey: "key",
|
||||
APISecret: "secret",
|
||||
Enabled: true,
|
||||
Rates: []RateConfigRequest{
|
||||
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
|
||||
{MinAmount: "100", MaxAmount: "0", GoldPerUSD: "82000"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
req := VerifyRechargeRequest{
|
||||
TargetUserID: 200,
|
||||
OrderNo: "order-1",
|
||||
TransferAmount: "12.5",
|
||||
TransferType: TransferTypeBinancePay,
|
||||
BillNo: "bill-1",
|
||||
}
|
||||
resp, err := service.VerifyAndRecharge(context.Background(), AuthUser{
|
||||
UserID: 100,
|
||||
SysOrigin: "LIKEI",
|
||||
}, req)
|
||||
if err != nil {
|
||||
t.Fatalf("verify recharge: %v", err)
|
||||
}
|
||||
if resp.Idempotent {
|
||||
t.Fatalf("first response idempotent = true, want false")
|
||||
}
|
||||
if resp.GoldAmount != 1_000_000 {
|
||||
t.Fatalf("GoldAmount = %d, want 1000000", resp.GoldAmount)
|
||||
}
|
||||
if java.changeCalls != 1 {
|
||||
t.Fatalf("changeCalls = %d, want 1", java.changeCalls)
|
||||
}
|
||||
if java.lastChange.RechargeType != rechargeTypeUSDT {
|
||||
t.Fatalf("RechargeType = %q, want %q", java.lastChange.RechargeType, rechargeTypeUSDT)
|
||||
}
|
||||
if java.lastChange.AcceptAmount != "1000000" {
|
||||
t.Fatalf("AcceptAmount = %q, want 1000000", java.lastChange.AcceptAmount)
|
||||
}
|
||||
if java.lastChange.OrderAmount != "12.5" {
|
||||
t.Fatalf("OrderAmount = %q, want 12.5", java.lastChange.OrderAmount)
|
||||
}
|
||||
|
||||
resp, err = service.VerifyAndRecharge(context.Background(), AuthUser{
|
||||
UserID: 100,
|
||||
SysOrigin: "LIKEI",
|
||||
}, req)
|
||||
if err != nil {
|
||||
t.Fatalf("verify idempotent recharge: %v", err)
|
||||
}
|
||||
if !resp.Idempotent {
|
||||
t.Fatalf("second response idempotent = false, want true")
|
||||
}
|
||||
if java.changeCalls != 1 {
|
||||
t.Fatalf("changeCalls after idempotent call = %d, want 1", java.changeCalls)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.BinanceRechargeVerifyRecord{}).Where("status = ?", verifyStatusSuccess).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count success records: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("success record count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func newBinanceRechargeTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.BinanceRechargeConfig{},
|
||||
&model.BinanceRechargeRate{},
|
||||
&model.BinanceRechargeVerifyRecord{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
type fakeBinanceVerifier struct{}
|
||||
|
||||
func (f *fakeBinanceVerifier) Verify(_ context.Context, _ BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||
return VerifiedTransaction{
|
||||
TransactionID: query.BillNo,
|
||||
OrderType: query.TransferType,
|
||||
Amount: query.Amount,
|
||||
RawJSON: `{"transactionId":"` + query.BillNo + `"}`,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeBinanceJavaGateway struct {
|
||||
profile integration.UserProfile
|
||||
balanceExists bool
|
||||
sellerExists bool
|
||||
changeCalls int
|
||||
lastChange integration.FreightBalanceChangeRequest
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
|
||||
if int64(f.profile.ID) == 0 {
|
||||
f.profile.ID = integration.Int64Value(userID)
|
||||
}
|
||||
return f.profile, nil
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) ExistsFreightBalance(context.Context, int64) (bool, error) {
|
||||
return f.balanceExists, nil
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) ExistsFreightSeller(context.Context, int64) (bool, error) {
|
||||
return f.sellerExists, nil
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) ChangeFreightBalance(_ context.Context, req integration.FreightBalanceChangeRequest) error {
|
||||
f.changeCalls++
|
||||
f.lastChange = req
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) AddFreightAgentReview(context.Context, integration.FreightAgentReviewRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBinanceJavaGateway) IncreaseFreightRechargeRecord(context.Context, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
150
internal/service/binancerecharge/types.go
Normal file
150
internal/service/binancerecharge/types.go
Normal file
@ -0,0 +1,150 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
TransferTypeBinancePay = "BINANCE_PAY"
|
||||
TransferTypeChain = "CHAIN"
|
||||
|
||||
verifyStatusPending = "PENDING"
|
||||
verifyStatusSuccess = "SUCCESS"
|
||||
verifyStatusFailed = "FAILED"
|
||||
|
||||
rechargeTypeUSDT = "USDT-进货"
|
||||
)
|
||||
|
||||
type AppError = common.AppError
|
||||
type AuthUser = common.AuthUser
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
|
||||
type binanceRechargeDB interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
type javaGateway interface {
|
||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||
ExistsFreightBalance(ctx context.Context, userID int64) (bool, error)
|
||||
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
|
||||
ChangeFreightBalance(ctx context.Context, req integration.FreightBalanceChangeRequest) error
|
||||
AddFreightAgentReview(ctx context.Context, req integration.FreightAgentReviewRequest) error
|
||||
IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error
|
||||
}
|
||||
|
||||
type binanceVerifier interface {
|
||||
Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db binanceRechargeDB
|
||||
java javaGateway
|
||||
verifier binanceVerifier
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db binanceRechargeDB, java javaGateway) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
java: java,
|
||||
verifier: NewHTTPBinanceVerifier(cfg.BinanceRecharge.BaseURL, cfg.HTTP.Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetBinanceVerifier(verifier binanceVerifier) {
|
||||
s.verifier = verifier
|
||||
}
|
||||
|
||||
type RateConfigRequest struct {
|
||||
MinAmount string `json:"minAmount"`
|
||||
MaxAmount string `json:"maxAmount"`
|
||||
GoldPerUSD string `json:"goldPerUsd"`
|
||||
}
|
||||
|
||||
type SaveConfigRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APISecret string `json:"apiSecret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Rates []RateConfigRequest `json:"rates"`
|
||||
}
|
||||
|
||||
type ConfigResponse struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APISecretConfigured bool `json:"apiSecretConfigured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Rates []RateConfigResponse `json:"rates"`
|
||||
}
|
||||
|
||||
type RateConfigResponse struct {
|
||||
ID int64 `json:"id,string,omitempty"`
|
||||
MinAmount string `json:"minAmount"`
|
||||
MaxAmount string `json:"maxAmount"`
|
||||
GoldPerUSD string `json:"goldPerUsd"`
|
||||
}
|
||||
|
||||
type VerifyRechargeRequest struct {
|
||||
TargetUserID int64 `json:"targetUserId,string"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
TransferAmount string `json:"transferAmount"`
|
||||
TransferType string `json:"transferType"`
|
||||
BillNo string `json:"billNo"`
|
||||
}
|
||||
|
||||
type VerifyRechargeResponse struct {
|
||||
ID int64 `json:"id,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
DealerUserID int64 `json:"dealerUserId,string"`
|
||||
TargetUserID int64 `json:"targetUserId,string"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
TransferType string `json:"transferType"`
|
||||
BillNo string `json:"billNo"`
|
||||
TransferAmount string `json:"transferAmount"`
|
||||
GoldAmount int64 `json:"goldAmount,string"`
|
||||
RateGoldPerUSD string `json:"rateGoldPerUsd"`
|
||||
Status string `json:"status"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
}
|
||||
|
||||
type BinanceCredential struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
type BinanceVerifyQuery struct {
|
||||
TransferType string
|
||||
BillNo string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type VerifiedTransaction struct {
|
||||
TransactionID string
|
||||
OrderType string
|
||||
Amount string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
func normalizeSysOrigin(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func validateTransferType(value string) (string, error) {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case TransferTypeBinancePay, TransferTypeChain:
|
||||
return normalized, nil
|
||||
default:
|
||||
return "", NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
|
||||
}
|
||||
}
|
||||
432
internal/service/binancerecharge/verify.go
Normal file
432
internal/service/binancerecharge/verify.go
Normal file
@ -0,0 +1,432 @@
|
||||
package binancerecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const pendingRetryAfter = 10 * time.Minute
|
||||
|
||||
var errDuplicateRechargeRecord = errors.New("duplicate binance recharge record")
|
||||
|
||||
type preparedRecharge struct {
|
||||
SysOrigin string
|
||||
DealerUserID int64
|
||||
TargetUserID int64
|
||||
OrderNo string
|
||||
TransferType string
|
||||
BillNo string
|
||||
AmountUSDT string
|
||||
GoldAmount int64
|
||||
RateGoldPerUSD string
|
||||
Verified VerifiedTransaction
|
||||
}
|
||||
|
||||
func (s *Service) VerifyAndRecharge(ctx context.Context, user AuthUser, req VerifyRechargeRequest) (*VerifyRechargeResponse, error) {
|
||||
if s.db == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
if s.verifier == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
|
||||
}
|
||||
|
||||
sysOrigin := normalizeSysOrigin(user.SysOrigin)
|
||||
if sysOrigin == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||
}
|
||||
if user.UserID <= 0 {
|
||||
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is invalid")
|
||||
}
|
||||
if req.TargetUserID <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "target_user_required", "targetUserId is required")
|
||||
}
|
||||
orderNo := strings.TrimSpace(req.OrderNo)
|
||||
if orderNo == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "order_no_required", "orderNo is required")
|
||||
}
|
||||
billNo := strings.TrimSpace(req.BillNo)
|
||||
if billNo == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "bill_no_required", "billNo is required")
|
||||
}
|
||||
transferType, err := validateTransferType(req.TransferType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amountRat, amount, err := parsePositiveDecimal(req.TransferAmount, "transferAmount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, rates, err := s.loadConfigAndRates(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rateRat, rateGoldPerUSD, err := matchRate(amountRat, rates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
goldAmount, err := calculateGold(amountRat, rateRat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if goldAmount <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "gold_amount_zero", "calculated gold amount must be greater than 0")
|
||||
}
|
||||
|
||||
profile, err := s.java.GetUserProfile(ctx, req.TargetUserID)
|
||||
if err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "target_user_query_failed", err.Error())
|
||||
}
|
||||
if normalizeSysOrigin(profile.OriginSys) != sysOrigin {
|
||||
return nil, NewAppError(http.StatusBadRequest, "target_user_origin_mismatch", "target user does not belong to current sysOrigin")
|
||||
}
|
||||
|
||||
verified, err := s.verifier.Verify(ctx, BinanceCredential{
|
||||
APIKey: cfg.APIKey,
|
||||
APISecret: cfg.APISecret,
|
||||
}, BinanceVerifyQuery{
|
||||
TransferType: transferType,
|
||||
BillNo: billNo,
|
||||
Amount: amount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(verified.TransactionID) == "" {
|
||||
verified.TransactionID = billNo
|
||||
}
|
||||
if strings.TrimSpace(verified.Amount) == "" {
|
||||
verified.Amount = amount
|
||||
}
|
||||
|
||||
prepared := preparedRecharge{
|
||||
SysOrigin: sysOrigin,
|
||||
DealerUserID: user.UserID,
|
||||
TargetUserID: req.TargetUserID,
|
||||
OrderNo: orderNo,
|
||||
TransferType: transferType,
|
||||
BillNo: billNo,
|
||||
AmountUSDT: amount,
|
||||
GoldAmount: goldAmount,
|
||||
RateGoldPerUSD: rateGoldPerUSD,
|
||||
Verified: verified,
|
||||
}
|
||||
record, idempotent, err := s.prepareRechargeRecord(ctx, prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idempotent {
|
||||
resp := responseFromRecord(record, true)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
if err := s.ensureFreightAccount(ctx, sysOrigin, req.TargetUserID); err != nil {
|
||||
_ = s.markRecordFailed(ctx, record.ID, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.java.ChangeFreightBalance(ctx, integration.FreightBalanceChangeRequest{
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: req.TargetUserID,
|
||||
AcceptUserID: req.TargetUserID,
|
||||
Type: "INCOME",
|
||||
AcceptAmount: strconv.FormatInt(goldAmount, 10),
|
||||
OrderAmount: amount,
|
||||
USDAmount: "0",
|
||||
Origin: "PURCHASE",
|
||||
Remark: fmt.Sprintf("Binance auto recharge order:%s bill:%s", orderNo, billNo),
|
||||
RechargeType: rechargeTypeUSDT,
|
||||
CreateUser: user.UserID,
|
||||
}); err != nil {
|
||||
_ = s.markRecordPendingIssue(ctx, record.ID, "freight balance change result is unknown: "+err.Error())
|
||||
return nil, NewAppError(http.StatusBadGateway, "freight_balance_change_failed", err.Error())
|
||||
}
|
||||
|
||||
remark := ""
|
||||
failReason := ""
|
||||
if err := s.java.IncreaseFreightRechargeRecord(ctx, req.TargetUserID, amount); err != nil {
|
||||
failReason = truncateString("monthly freight recharge record update failed: "+err.Error(), 500)
|
||||
remark = failReason
|
||||
}
|
||||
if err := s.markRecordSuccess(ctx, record.ID, remark, failReason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record.Status = verifyStatusSuccess
|
||||
record.Remark = remark
|
||||
record.FailReason = failReason
|
||||
resp := responseFromRecord(record, false)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadConfigAndRates(ctx context.Context, sysOrigin string) (model.BinanceRechargeConfig, []model.BinanceRechargeRate, error) {
|
||||
var cfg model.BinanceRechargeConfig
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
First(&cfg).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_missing", "binance recharge config is missing")
|
||||
}
|
||||
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_config_query_failed", err.Error())
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_disabled", "binance recharge verification is disabled")
|
||||
}
|
||||
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.APISecret) == "" {
|
||||
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_credentials_missing", "binance api key and secret are required")
|
||||
}
|
||||
|
||||
var rates []model.BinanceRechargeRate
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("config_id = ?", cfg.ID).
|
||||
Order("sort_order asc, min_amount asc, id asc").
|
||||
Find(&rates).Error; err != nil {
|
||||
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_rate_query_failed", err.Error())
|
||||
}
|
||||
if len(rates) == 0 {
|
||||
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_rates_missing", "binance recharge rates are missing")
|
||||
}
|
||||
return cfg, rates, nil
|
||||
}
|
||||
|
||||
func matchRate(amount *big.Rat, rates []model.BinanceRechargeRate) (*big.Rat, string, error) {
|
||||
for _, row := range rates {
|
||||
minRat, _, err := parseNonNegativeDecimal(row.MinAmount, "minAmount")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
maxRat, _, err := parseNonNegativeDecimal(defaultZero(row.MaxAmount), "maxAmount")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
rateRat, rateGoldPerUSD, err := parsePositiveDecimal(row.GoldPerUSD, "goldPerUsd")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if amount.Cmp(minRat) < 0 {
|
||||
continue
|
||||
}
|
||||
if maxRat.Sign() > 0 && amount.Cmp(maxRat) >= 0 {
|
||||
continue
|
||||
}
|
||||
return rateRat, rateGoldPerUSD, nil
|
||||
}
|
||||
return nil, "", NewAppError(http.StatusBadRequest, "binance_rate_not_matched", "transfer amount does not match any configured rate range")
|
||||
}
|
||||
|
||||
func (s *Service) prepareRechargeRecord(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
|
||||
var record model.BinanceRechargeVerifyRecord
|
||||
var idempotent bool
|
||||
var err error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
record, idempotent, err = s.prepareRechargeRecordOnce(ctx, prepared)
|
||||
if errors.Is(err, errDuplicateRechargeRecord) {
|
||||
continue
|
||||
}
|
||||
return record, idempotent, err
|
||||
}
|
||||
return record, idempotent, err
|
||||
}
|
||||
|
||||
func (s *Service) prepareRechargeRecordOnce(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
|
||||
var out model.BinanceRechargeVerifyRecord
|
||||
var idempotent bool
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing []model.BinanceRechargeVerifyRecord
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("sys_origin = ? AND (order_no = ? OR (transfer_type = ? AND bill_no = ?))",
|
||||
prepared.SysOrigin,
|
||||
prepared.OrderNo,
|
||||
prepared.TransferType,
|
||||
prepared.BillNo,
|
||||
).
|
||||
Limit(2).
|
||||
Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) > 1 {
|
||||
return NewAppError(http.StatusConflict, "recharge_order_or_bill_used", "orderNo or billNo has already been used")
|
||||
}
|
||||
now := time.Now()
|
||||
if len(existing) == 1 {
|
||||
record := existing[0]
|
||||
switch record.Status {
|
||||
case verifyStatusSuccess:
|
||||
out = record
|
||||
idempotent = true
|
||||
return nil
|
||||
case verifyStatusPending:
|
||||
if now.Sub(record.UpdateTime) < pendingRetryAfter {
|
||||
return NewAppError(http.StatusConflict, "recharge_verification_processing", "recharge verification is processing")
|
||||
}
|
||||
}
|
||||
updates := recordValues(prepared, now, verifyStatusPending)
|
||||
if err := tx.Model(&model.BinanceRechargeVerifyRecord{}).
|
||||
Where("id = ?", record.ID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id = ?", record.ID).First(&out).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
record := model.BinanceRechargeVerifyRecord{
|
||||
SysOrigin: prepared.SysOrigin,
|
||||
DealerUserID: prepared.DealerUserID,
|
||||
TargetUserID: prepared.TargetUserID,
|
||||
OrderNo: prepared.OrderNo,
|
||||
TransferType: prepared.TransferType,
|
||||
BillNo: prepared.BillNo,
|
||||
AmountUSDT: prepared.AmountUSDT,
|
||||
GoldAmount: prepared.GoldAmount,
|
||||
RateGoldPerUSD: prepared.RateGoldPerUSD,
|
||||
BinanceTransactionID: prepared.Verified.TransactionID,
|
||||
BinanceOrderType: prepared.Verified.OrderType,
|
||||
BinanceRawJSON: prepared.Verified.RawJSON,
|
||||
Status: verifyStatusPending,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
if isDuplicateRecordError(err) {
|
||||
return errDuplicateRechargeRecord
|
||||
}
|
||||
return err
|
||||
}
|
||||
out = record
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return out, idempotent, err
|
||||
}
|
||||
return out, idempotent, NewAppError(http.StatusInternalServerError, "recharge_record_prepare_failed", err.Error())
|
||||
}
|
||||
return out, idempotent, nil
|
||||
}
|
||||
|
||||
func recordValues(prepared preparedRecharge, now time.Time, status string) map[string]any {
|
||||
return map[string]any{
|
||||
"dealer_user_id": prepared.DealerUserID,
|
||||
"target_user_id": prepared.TargetUserID,
|
||||
"order_no": prepared.OrderNo,
|
||||
"transfer_type": prepared.TransferType,
|
||||
"bill_no": prepared.BillNo,
|
||||
"amount_usdt": prepared.AmountUSDT,
|
||||
"gold_amount": prepared.GoldAmount,
|
||||
"rate_gold_per_usd": prepared.RateGoldPerUSD,
|
||||
"binance_transaction_id": prepared.Verified.TransactionID,
|
||||
"binance_order_type": prepared.Verified.OrderType,
|
||||
"binance_raw_json": prepared.Verified.RawJSON,
|
||||
"status": status,
|
||||
"fail_reason": "",
|
||||
"remark": "",
|
||||
"update_time": now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ensureFreightAccount(ctx context.Context, sysOrigin string, targetUserID int64) error {
|
||||
exists, err := s.java.ExistsFreightBalance(ctx, targetUserID)
|
||||
if err != nil {
|
||||
return NewAppError(http.StatusBadGateway, "freight_balance_query_failed", err.Error())
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
existsSeller, err := s.java.ExistsFreightSeller(ctx, targetUserID)
|
||||
if err != nil {
|
||||
return NewAppError(http.StatusBadGateway, "freight_seller_query_failed", err.Error())
|
||||
}
|
||||
if existsSeller {
|
||||
return NewAppError(http.StatusConflict, "freight_seller_exists_without_balance", "target user is already a freight seller without balance account")
|
||||
}
|
||||
if err := s.java.AddFreightAgentReview(ctx, integration.FreightAgentReviewRequest{
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: targetUserID,
|
||||
}); err != nil {
|
||||
return NewAppError(http.StatusBadGateway, "freight_agent_create_failed", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) markRecordFailed(ctx context.Context, id int64, reason string) error {
|
||||
return s.updateRecordStatus(ctx, id, verifyStatusFailed, "", truncateString(reason, 500))
|
||||
}
|
||||
|
||||
func (s *Service) markRecordPendingIssue(ctx context.Context, id int64, reason string) error {
|
||||
return s.updateRecordStatus(ctx, id, verifyStatusPending, "", truncateString(reason, 500))
|
||||
}
|
||||
|
||||
func (s *Service) markRecordSuccess(ctx context.Context, id int64, remark string, failReason string) error {
|
||||
return s.updateRecordStatus(ctx, id, verifyStatusSuccess, truncateString(remark, 500), truncateString(failReason, 500))
|
||||
}
|
||||
|
||||
func (s *Service) updateRecordStatus(ctx context.Context, id int64, status string, remark string, failReason string) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.BinanceRechargeVerifyRecord{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"remark": remark,
|
||||
"fail_reason": failReason,
|
||||
"update_time": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return NewAppError(http.StatusInternalServerError, "recharge_record_update_failed", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responseFromRecord(record model.BinanceRechargeVerifyRecord, idempotent bool) VerifyRechargeResponse {
|
||||
return VerifyRechargeResponse{
|
||||
ID: record.ID,
|
||||
SysOrigin: record.SysOrigin,
|
||||
DealerUserID: record.DealerUserID,
|
||||
TargetUserID: record.TargetUserID,
|
||||
OrderNo: record.OrderNo,
|
||||
TransferType: record.TransferType,
|
||||
BillNo: record.BillNo,
|
||||
TransferAmount: trimDecimalZeros(record.AmountUSDT),
|
||||
GoldAmount: record.GoldAmount,
|
||||
RateGoldPerUSD: trimDecimalZeros(record.RateGoldPerUSD),
|
||||
Status: record.Status,
|
||||
Idempotent: idempotent,
|
||||
}
|
||||
}
|
||||
|
||||
func isDuplicateRecordError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "duplicate") ||
|
||||
strings.Contains(message, "unique constraint") ||
|
||||
strings.Contains(message, "duplicated key")
|
||||
}
|
||||
|
||||
func truncateString(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit]
|
||||
}
|
||||
@ -288,7 +288,7 @@ func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string {
|
||||
}
|
||||
|
||||
func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string {
|
||||
return defaultIfBlank(cfg.TestToken, user.Token)
|
||||
return trimTokenPadding(defaultIfBlank(cfg.TestToken, user.Token))
|
||||
}
|
||||
|
||||
func launchRoomID(cfg runtimeConfig, req gameprovider.LaunchRequest) string {
|
||||
@ -307,6 +307,10 @@ func launchLang(req gameprovider.LaunchRequest) string {
|
||||
return defaultLaunchLang
|
||||
}
|
||||
|
||||
func trimTokenPadding(token string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(token), "=")
|
||||
}
|
||||
|
||||
func launchSign(cfg runtimeConfig, gameID, uid, token, roomID string) string {
|
||||
sum := md5.Sum([]byte(gameID + uid + token + roomID + strings.TrimSpace(cfg.AppKey)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@ -355,7 +355,7 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
|
||||
Active: true,
|
||||
AppKey: "test-key",
|
||||
TestUID: "test-uid",
|
||||
TestToken: "test-token",
|
||||
TestToken: "test-token==",
|
||||
TestRoomID: "test-room",
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
@ -466,6 +466,9 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
|
||||
if got := query.Get("token"); got != "test-token" {
|
||||
t.Fatalf("query token = %q, want test-token", got)
|
||||
}
|
||||
if got := query.Get("sign"); got != launchSign(runtimeConfig{AppKey: "test-key"}, "1", "test-uid", "test-token", "test-room") {
|
||||
t.Fatalf("query sign = %q, want sign with trimmed token", got)
|
||||
}
|
||||
if got := query.Get("lang"); got != defaultLaunchLang {
|
||||
t.Fatalf("query lang = %q, want %s", got, defaultLaunchLang)
|
||||
}
|
||||
@ -478,6 +481,9 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
|
||||
if got := launch.LaunchConfig.(map[string]any)["roomid"]; got != "test-room" {
|
||||
t.Fatalf("launchConfig.roomid = %v, want test-room", got)
|
||||
}
|
||||
if got := launch.LaunchConfig.(map[string]any)["token"]; got != "test-token" {
|
||||
t.Fatalf("launchConfig.token = %v, want trimmed token", got)
|
||||
}
|
||||
if got := launch.LaunchConfig.(map[string]any)["lang"]; got != defaultLaunchLang {
|
||||
t.Fatalf("launchConfig.lang = %v, want %s", got, defaultLaunchLang)
|
||||
}
|
||||
|
||||
721
internal/service/managercenter/service.go
Normal file
721
internal/service/managercenter/service.go
Normal file
@ -0,0 +1,721 @@
|
||||
package managercenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type administratorRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Status bool `gorm:"column:status"`
|
||||
}
|
||||
|
||||
func (administratorRow) TableName() string {
|
||||
return "sys_administrator"
|
||||
}
|
||||
|
||||
type userBaseInfoRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Account string `gorm:"column:account"`
|
||||
UserAvatar string `gorm:"column:user_avatar"`
|
||||
UserNickname string `gorm:"column:user_nickname"`
|
||||
CountryCode string `gorm:"column:country_code"`
|
||||
CountryName string `gorm:"column:country_name"`
|
||||
OriginSys string `gorm:"column:origin_sys"`
|
||||
AccountStatus string `gorm:"column:account_status"`
|
||||
IsDel bool `gorm:"column:is_del"`
|
||||
}
|
||||
|
||||
func (userBaseInfoRow) TableName() string {
|
||||
return "user_base_info"
|
||||
}
|
||||
|
||||
type propsSourceRecordRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Type string `gorm:"column:type"`
|
||||
Code string `gorm:"column:code"`
|
||||
Name string `gorm:"column:name"`
|
||||
Cover string `gorm:"column:cover"`
|
||||
SourceURL string `gorm:"column:source_url"`
|
||||
Expand string `gorm:"column:expand"`
|
||||
AdminFree bool `gorm:"column:admin_free"`
|
||||
IsDel bool `gorm:"column:is_del"`
|
||||
}
|
||||
|
||||
func (propsSourceRecordRow) TableName() string {
|
||||
return "props_source_record"
|
||||
}
|
||||
|
||||
type propsNobleVIPAbilityRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
VIPLevel int `gorm:"column:vip_level"`
|
||||
AvatarFrameID int64 `gorm:"column:avatar_frame_id"`
|
||||
CarID int64 `gorm:"column:car_id"`
|
||||
ChatBubbleID int64 `gorm:"column:chat_bubble_id"`
|
||||
DataCardID int64 `gorm:"column:data_card_id"`
|
||||
}
|
||||
|
||||
func (propsNobleVIPAbilityRow) TableName() string {
|
||||
return "props_noble_vip_ability"
|
||||
}
|
||||
|
||||
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view, err := s.loadUserViewByID(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileResponse{Manager: view}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account = strings.TrimSpace(account)
|
||||
if account == "" {
|
||||
return nil, badRequest("account_required", "account is required")
|
||||
}
|
||||
|
||||
view, err := s.findUserView(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserSearchResponse{User: view}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
propsType = normalizePropsType(propsType)
|
||||
if propsType == propsTypeNobleVIP {
|
||||
return s.listVIPProps(ctx, user)
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("props_source_record").
|
||||
Select("id, type, code, name, cover, source_url, expand, admin_free, is_del").
|
||||
Where("is_del = ? AND admin_free = ?", false, true)
|
||||
if propsType != "" {
|
||||
query = query.Where("type = ?", propsType)
|
||||
}
|
||||
|
||||
var rows []propsSourceRecordRow
|
||||
if err := query.Order("type ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, serverError("props_query_failed", err.Error())
|
||||
}
|
||||
|
||||
vipLevels, err := s.loadVIPLevels(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]PropsView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
view, ok := propsRowToView(row, vipLevels)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
records = append(records, view)
|
||||
}
|
||||
|
||||
return &PropsListResponse{
|
||||
Type: propsType,
|
||||
Records: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AcceptUserID.Int64() <= 0 {
|
||||
return nil, badRequest("accept_user_required", "acceptUserId is required")
|
||||
}
|
||||
if req.PropsID.Int64() <= 0 {
|
||||
return nil, badRequest("props_required", "propsId is required")
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
|
||||
target, err := s.loadUserRowByID(ctx, req.AcceptUserID.Int64())
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return nil, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
|
||||
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
|
||||
if err != nil {
|
||||
if isAppErrorCode(err, "prop_not_giftable") {
|
||||
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil {
|
||||
return nil, vipErr
|
||||
} else if resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
days := propsGiftDays(prop.Type)
|
||||
grants := []propsGrant{{
|
||||
propsID: prop.ID,
|
||||
typ: prop.Type,
|
||||
days: days,
|
||||
}}
|
||||
if prop.Type == propsTypeNobleVIP {
|
||||
if _, err := s.loadGiftableNobleVIPAbility(ctx, prop.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ability, err := s.java.GetNobleVIPAbility(ctx, prop.ID)
|
||||
if err != nil {
|
||||
return nil, serverError("vip_ability_query_failed", err.Error())
|
||||
}
|
||||
grants = append(grants, nobleVIPAccessoryGrants(ability)...)
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
|
||||
AcceptUserID: req.AcceptUserID.Int64(),
|
||||
PropsID: grant.propsID,
|
||||
Type: grant.typ,
|
||||
Origin: adminFreeOrigin,
|
||||
OriginDesc: adminFreeOriginDesc,
|
||||
Days: grant.days,
|
||||
UseProps: true,
|
||||
AllowGive: false,
|
||||
OpUser: user.UserID,
|
||||
}); err != nil {
|
||||
return nil, serverError("send_props_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if prop.Type == propsTypeNobleVIP {
|
||||
if err := s.switchMaxNobleVIP(ctx, req.AcceptUserID.Int64()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &SendPropsResponse{
|
||||
Success: true,
|
||||
AcceptUserID: req.AcceptUserID,
|
||||
PropsID: req.PropsID,
|
||||
Type: prop.Type,
|
||||
Days: days,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
return nil, badRequest("user_required", "userId is required")
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
|
||||
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return nil, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
if isUnbannableAccountStatus(target.AccountStatus) {
|
||||
return nil, badRequest("user_already_banned", "user is already banned")
|
||||
}
|
||||
|
||||
hasRecharge, err := s.userHasRecharge(ctx, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasRecharge {
|
||||
return nil, badRequest("user_recharged", "only users without recharge can be banned")
|
||||
}
|
||||
|
||||
description := buildBanDescription(user.UserID, req.Reason)
|
||||
if err := s.java.PunishAccount(ctx, integration.PunishAccountRequest{
|
||||
BeOptUserID: target.ID,
|
||||
OptUserID: user.UserID,
|
||||
Status: accountStatusArchive,
|
||||
Description: description,
|
||||
}); err != nil {
|
||||
return nil, serverError("ban_user_failed", err.Error())
|
||||
}
|
||||
|
||||
return &BanUserResponse{
|
||||
Success: true,
|
||||
UserID: req.UserID,
|
||||
AccountStatus: accountStatusArchive,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
return nil, badRequest("user_required", "userId is required")
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
|
||||
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return nil, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
if !isUnbannableAccountStatus(target.AccountStatus) {
|
||||
return nil, badRequest("user_not_banned", "only banned or frozen users can be unbanned")
|
||||
}
|
||||
|
||||
description := buildUnbanDescription(user.UserID, req.Reason)
|
||||
if err := s.java.UnblockAccount(ctx, integration.UnblockAccountRequest{
|
||||
BeOptUserID: target.ID,
|
||||
OptUserID: user.UserID,
|
||||
Description: description,
|
||||
}); err != nil {
|
||||
return nil, serverError("unban_user_failed", err.Error())
|
||||
}
|
||||
|
||||
return &UnbanUserResponse{
|
||||
Success: true,
|
||||
UserID: req.UserID,
|
||||
AccountStatus: accountStatusNormal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||
if user.UserID <= 0 {
|
||||
return unauthorized("invalid_user", "authenticated user is required")
|
||||
}
|
||||
if s.db == nil {
|
||||
return serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("sys_administrator").
|
||||
Where("user_id = ? AND status = ?", user.UserID, true).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return serverError("manager_query_failed", err.Error())
|
||||
}
|
||||
if count == 0 {
|
||||
return forbidden("not_manager", "user is not manager")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
|
||||
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
|
||||
if row, err := s.loadUserRowByID(ctx, userID); err == nil {
|
||||
return s.userRowToView(ctx, row)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return UserView{}, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if s.java != nil {
|
||||
profile, err := s.java.GetUserProfileByAccountOne(ctx, account)
|
||||
if err == nil && int64(profile.ID) > 0 {
|
||||
if row, rowErr := s.loadUserRowByID(ctx, int64(profile.ID)); rowErr == nil {
|
||||
view, viewErr := s.userRowToView(ctx, row)
|
||||
if viewErr != nil {
|
||||
return UserView{}, viewErr
|
||||
}
|
||||
overlayJavaProfile(&view, profile)
|
||||
return view, nil
|
||||
} else if !errors.Is(rowErr, gorm.ErrRecordNotFound) {
|
||||
return UserView{}, serverError("user_query_failed", rowErr.Error())
|
||||
}
|
||||
return s.javaProfileToView(ctx, profile)
|
||||
}
|
||||
}
|
||||
|
||||
row, err := s.loadUserRowByAccount(ctx, account)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return UserView{}, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return UserView{}, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
return s.userRowToView(ctx, row)
|
||||
}
|
||||
|
||||
func (s *Service) loadUserViewByID(ctx context.Context, userID int64) (UserView, error) {
|
||||
row, err := s.loadUserRowByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return UserView{}, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return UserView{}, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
return s.userRowToView(ctx, row)
|
||||
}
|
||||
|
||||
func (s *Service) loadUserRowByID(ctx context.Context, userID int64) (userBaseInfoRow, error) {
|
||||
var row userBaseInfoRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("user_base_info").
|
||||
Where("id = ? AND is_del = ?", userID, false).
|
||||
First(&row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (userBaseInfoRow, error) {
|
||||
var row userBaseInfoRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("user_base_info").
|
||||
Where("account = ? AND is_del = ?", account, false).
|
||||
First(&row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
||||
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
|
||||
return UserView{
|
||||
UserID: ID(row.ID),
|
||||
Account: row.Account,
|
||||
UserAvatar: row.UserAvatar,
|
||||
UserNickname: row.UserNickname,
|
||||
CountryCode: row.CountryCode,
|
||||
CountryName: row.CountryName,
|
||||
OriginSys: row.OriginSys,
|
||||
AccountStatus: row.AccountStatus,
|
||||
HasRecharge: hasRecharge,
|
||||
CanBan: canBan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) javaProfileToView(ctx context.Context, profile integration.UserProfile) (UserView, error) {
|
||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID))
|
||||
canBan = canBan && !isUnbannableAccountStatus(profile.AccountStatus)
|
||||
return UserView{
|
||||
UserID: ID(profile.ID),
|
||||
Account: profile.Account,
|
||||
UserAvatar: profile.UserAvatar,
|
||||
UserNickname: profile.UserNickname,
|
||||
CountryCode: profile.CountryCode,
|
||||
CountryName: profile.CountryName,
|
||||
OriginSys: profile.OriginSys,
|
||||
AccountStatus: profile.AccountStatus,
|
||||
HasRecharge: hasRecharge,
|
||||
CanBan: canBan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
||||
if profile.Account != "" {
|
||||
view.Account = profile.Account
|
||||
}
|
||||
if profile.UserAvatar != "" {
|
||||
view.UserAvatar = profile.UserAvatar
|
||||
}
|
||||
if profile.UserNickname != "" {
|
||||
view.UserNickname = profile.UserNickname
|
||||
}
|
||||
if profile.CountryCode != "" {
|
||||
view.CountryCode = profile.CountryCode
|
||||
}
|
||||
if profile.CountryName != "" {
|
||||
view.CountryName = profile.CountryName
|
||||
}
|
||||
if profile.OriginSys != "" {
|
||||
view.OriginSys = profile.OriginSys
|
||||
}
|
||||
if profile.AccountStatus != "" {
|
||||
view.AccountStatus = profile.AccountStatus
|
||||
if isUnbannableAccountStatus(profile.AccountStatus) {
|
||||
view.CanBan = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if s.java == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID})
|
||||
if err != nil {
|
||||
return false, serverError("recharge_query_failed", err.Error())
|
||||
}
|
||||
for _, item := range recharges[userID] {
|
||||
raw := strings.TrimSpace(string(item.Amount))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
amount, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return false, serverError("recharge_query_failed", err.Error())
|
||||
}
|
||||
if amount > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) userRechargeEligibility(ctx context.Context, userID int64) (bool, bool) {
|
||||
hasRecharge, err := s.userHasRecharge(ctx, userID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return hasRecharge, !hasRecharge
|
||||
}
|
||||
|
||||
func (s *Service) loadGiftableProps(ctx context.Context, propsID int64) (propsSourceRecordRow, error) {
|
||||
var row propsSourceRecordRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("props_source_record").
|
||||
Select("id, type, code, name, cover, source_url, expand, admin_free, is_del").
|
||||
Where("id = ? AND is_del = ? AND admin_free = ?", propsID, false, true).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return propsSourceRecordRow{}, badRequest("prop_not_giftable", "props is not admin-free giftable")
|
||||
}
|
||||
if err != nil {
|
||||
return propsSourceRecordRow{}, serverError("props_query_failed", err.Error())
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64) (propsNobleVIPAbilityRow, error) {
|
||||
var row propsNobleVIPAbilityRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("props_noble_vip_ability").
|
||||
Where("id = ?", propsID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "noble VIP ability is not giftable")
|
||||
}
|
||||
if err != nil {
|
||||
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
|
||||
}
|
||||
if row.VIPLevel > 3 {
|
||||
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadVIPLevels(ctx context.Context, rows []propsSourceRecordRow) (map[int64]int, error) {
|
||||
ids := make([]int64, 0)
|
||||
for _, row := range rows {
|
||||
if row.Type == propsTypeNobleVIP {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var abilities []propsNobleVIPAbilityRow
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("props_noble_vip_ability").
|
||||
Select("id, vip_level").
|
||||
Where("id IN ?", ids).
|
||||
Find(&abilities).Error; err != nil {
|
||||
return nil, serverError("vip_ability_query_failed", err.Error())
|
||||
}
|
||||
|
||||
levels := make(map[int64]int, len(abilities))
|
||||
for _, ability := range abilities {
|
||||
levels[ability.ID] = ability.VIPLevel
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
type propsGrant struct {
|
||||
propsID int64
|
||||
typ string
|
||||
days int
|
||||
}
|
||||
|
||||
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
|
||||
grants := make([]propsGrant, 0, 4)
|
||||
appendIfPositive := func(propsID int64, typ string) {
|
||||
if propsID <= 0 {
|
||||
return
|
||||
}
|
||||
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays})
|
||||
}
|
||||
appendIfPositive(int64(ability.CarID), propsTypeRide)
|
||||
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
|
||||
appendIfPositive(int64(ability.ChatBubbleID), propsTypeChatBubble)
|
||||
appendIfPositive(int64(ability.DataCardID), propsTypeDataCard)
|
||||
return grants
|
||||
}
|
||||
|
||||
func (s *Service) switchMaxNobleVIP(ctx context.Context, userID int64) error {
|
||||
ability, err := s.java.GetUserMaxNobleVIPAbility(ctx, userID)
|
||||
if err != nil {
|
||||
return serverError("vip_ability_query_failed", err.Error())
|
||||
}
|
||||
|
||||
ids := []int64{
|
||||
int64(ability.ID),
|
||||
int64(ability.AvatarFrameID),
|
||||
int64(ability.DataCardID),
|
||||
int64(ability.CarID),
|
||||
int64(ability.ChatBubbleID),
|
||||
}
|
||||
for _, propsID := range compactPositiveIDs(ids) {
|
||||
if err := s.java.SwitchUseProps(ctx, userID, propsID); err != nil {
|
||||
return serverError("switch_props_failed", err.Error())
|
||||
}
|
||||
}
|
||||
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
|
||||
return serverError("remove_profile_cache_failed", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compactPositiveIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsView, bool) {
|
||||
level := 0
|
||||
if row.Type == propsTypeNobleVIP {
|
||||
var exists bool
|
||||
level, exists = vipLevels[row.ID]
|
||||
if !exists || level > 3 {
|
||||
return PropsView{}, false
|
||||
}
|
||||
}
|
||||
return PropsView{
|
||||
ID: ID(row.ID),
|
||||
Type: row.Type,
|
||||
Code: row.Code,
|
||||
Name: row.Name,
|
||||
Cover: row.Cover,
|
||||
SourceURL: row.SourceURL,
|
||||
Expand: row.Expand,
|
||||
Days: propsGiftDays(row.Type),
|
||||
VIPLevel: level,
|
||||
}, true
|
||||
}
|
||||
|
||||
func propsGiftDays(propsType string) int {
|
||||
if propsType == propsTypeNobleVIP {
|
||||
return defaultVIPGiftDays
|
||||
}
|
||||
return defaultGiftDays
|
||||
}
|
||||
|
||||
func normalizePropsType(propsType string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(propsType))
|
||||
}
|
||||
|
||||
func buildBanDescription(managerID int64, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len([]rune(reason)) > 200 {
|
||||
runes := []rune(reason)
|
||||
reason = string(runes[:200])
|
||||
}
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("manager center ban non-recharged user, manager=%d", managerID)
|
||||
}
|
||||
return fmt.Sprintf("manager center ban non-recharged user, manager=%d, reason=%s", managerID, reason)
|
||||
}
|
||||
|
||||
func buildUnbanDescription(managerID int64, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len([]rune(reason)) > 200 {
|
||||
runes := []rune(reason)
|
||||
reason = string(runes[:200])
|
||||
}
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("manager center unban user, manager=%d", managerID)
|
||||
}
|
||||
return fmt.Sprintf("manager center unban user, manager=%d, reason=%s", managerID, reason)
|
||||
}
|
||||
|
||||
func isUnbannableAccountStatus(status string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case accountStatusFreeze, accountStatusArchive, accountStatusArchiveDevice:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAppErrorCode(err error, code string) bool {
|
||||
var appErr *AppError
|
||||
return errors.As(err, &appErr) && appErr.Code == code
|
||||
}
|
||||
|
||||
func normalizeSysOrigin(sysOrigin string) string {
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
if sysOrigin == "" {
|
||||
return "LIKEI"
|
||||
}
|
||||
return sysOrigin
|
||||
}
|
||||
|
||||
func vipPropsViewFromConfig(row model.VipLevelConfig) PropsView {
|
||||
days := row.DurationDays
|
||||
if days <= 0 {
|
||||
days = defaultVIPGiftDays
|
||||
}
|
||||
name := strings.TrimSpace(row.DisplayName)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("VIP %d", row.Level)
|
||||
}
|
||||
code := strings.TrimSpace(row.LevelCode)
|
||||
if code == "" {
|
||||
code = fmt.Sprintf("VIP%d", row.Level)
|
||||
}
|
||||
return PropsView{
|
||||
ID: ID(row.ID),
|
||||
Type: propsTypeNobleVIP,
|
||||
Code: code,
|
||||
Name: name,
|
||||
Cover: row.BadgeURL,
|
||||
SourceURL: row.BadgeURL,
|
||||
Days: days,
|
||||
VIPLevel: row.Level,
|
||||
}
|
||||
}
|
||||
516
internal/service/managercenter/service_test.go
Normal file
516
internal/service/managercenter/service_test.go
Normal file
@ -0,0 +1,516 @@
|
||||
package managercenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeManagerGateway struct {
|
||||
recharges map[int64][]integration.UserTotalRecharge
|
||||
rechargeErr error
|
||||
abilities map[int64]integration.PropsNobleVIPAbility
|
||||
maxAbility integration.PropsNobleVIPAbility
|
||||
giveRequests []integration.GivePropsBackpackRequest
|
||||
punishRequests []integration.PunishAccountRequest
|
||||
unblockRequests []integration.UnblockAccountRequest
|
||||
switchIDs []int64
|
||||
cacheCleared []int64
|
||||
badgeIDs []int64
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) {
|
||||
return integration.UserProfile{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
|
||||
return integration.UserProfile{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) {
|
||||
if f.rechargeErr != nil {
|
||||
return nil, f.rechargeErr
|
||||
}
|
||||
result := make(map[int64][]integration.UserTotalRecharge, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
result[userID] = f.recharges[userID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
||||
f.giveRequests = append(f.giveRequests, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GetNobleVIPAbility(_ context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) {
|
||||
return f.abilities[sourceID], nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GetUserMaxNobleVIPAbility(context.Context, int64) (integration.PropsNobleVIPAbility, error) {
|
||||
return f.maxAbility, nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) SwitchUseProps(_ context.Context, _ int64, propsID int64) error {
|
||||
f.switchIDs = append(f.switchIDs, propsID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) ActivateTemporaryBadge(_ context.Context, _ int64, badgeID int64, _ int) error {
|
||||
f.badgeIDs = append(f.badgeIDs, badgeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) RemoveUserProfileCacheAll(_ context.Context, userID int64) error {
|
||||
f.cacheCleared = append(f.cacheCleared, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) PunishAccount(_ context.Context, req integration.PunishAccountRequest) error {
|
||||
f.punishRequests = append(f.punishRequests, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) UnblockAccount(_ context.Context, req integration.UnblockAccountRequest) error {
|
||||
f.unblockRequests = append(f.unblockRequests, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
seedManager(t, service.db.(*gorm.DB), 1)
|
||||
seedProps(t, service.db.(*gorm.DB),
|
||||
propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true},
|
||||
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
|
||||
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
|
||||
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
|
||||
)
|
||||
seedAbilities(t, service.db.(*gorm.DB),
|
||||
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
|
||||
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
|
||||
)
|
||||
|
||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProps() error = %v", err)
|
||||
}
|
||||
|
||||
got := map[int64]PropsView{}
|
||||
for _, item := range resp.Records {
|
||||
got[item.ID.Int64()] = item
|
||||
}
|
||||
if _, ok := got[101]; !ok {
|
||||
t.Fatalf("records = %+v, want giftable ride", resp.Records)
|
||||
}
|
||||
if _, ok := got[102]; ok {
|
||||
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
|
||||
}
|
||||
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
|
||||
t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item)
|
||||
}
|
||||
if _, ok := got[204]; ok {
|
||||
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedVIPConfigs(t, db,
|
||||
model.VipLevelConfig{ID: 301, SysOrigin: "LIKEI", Level: 1, LevelCode: "VIP1", DisplayName: "VIP 1", Enabled: true, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
|
||||
)
|
||||
|
||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProps() error = %v", err)
|
||||
}
|
||||
if len(resp.Records) != 1 {
|
||||
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
|
||||
}
|
||||
got := resp.Records[0]
|
||||
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
|
||||
t.Fatalf("vip props = %+v, want config 301 level 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"})
|
||||
fake.rechargeErr = errors.New("order service unavailable")
|
||||
|
||||
resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile() error = %v", err)
|
||||
}
|
||||
if resp.Manager.HasRecharge || resp.Manager.CanBan {
|
||||
t.Fatalf("manager = %+v, want unknown recharge state and ban disabled", resp.Manager)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
seedProps(t, db, propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false})
|
||||
|
||||
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
|
||||
AcceptUserID: ID(2),
|
||||
PropsID: ID(102),
|
||||
})
|
||||
assertAppErrorCode(t, err, "prop_not_giftable")
|
||||
if len(fake.giveRequests) != 0 {
|
||||
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
seedProps(t, db, propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true})
|
||||
seedAbilities(t, db, propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3})
|
||||
fake.abilities[201] = integration.PropsNobleVIPAbility{
|
||||
ID: integration.Int64Value(201),
|
||||
AvatarFrameID: integration.Int64Value(302),
|
||||
CarID: integration.Int64Value(301),
|
||||
ChatBubbleID: integration.Int64Value(303),
|
||||
DataCardID: integration.Int64Value(304),
|
||||
}
|
||||
fake.maxAbility = fake.abilities[201]
|
||||
|
||||
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
|
||||
AcceptUserID: ID(2),
|
||||
PropsID: ID(201),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendProps() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Days != defaultVIPGiftDays {
|
||||
t.Fatalf("resp = %+v, want success and VIP days", resp)
|
||||
}
|
||||
if len(fake.giveRequests) != 5 {
|
||||
t.Fatalf("give requests = %+v, want main VIP plus 4 accessories", fake.giveRequests)
|
||||
}
|
||||
for _, req := range fake.giveRequests {
|
||||
if req.AcceptUserID != 2 || req.Origin != adminFreeOrigin || req.OpUser != 1 || !req.UseProps || req.AllowGive {
|
||||
t.Fatalf("give request = %+v, want admin-free grant to target", req)
|
||||
}
|
||||
}
|
||||
wantSwitch := []int64{201, 302, 304, 301, 303}
|
||||
if !equalInt64Slice(fake.switchIDs, wantSwitch) {
|
||||
t.Fatalf("switch ids = %+v, want %+v", fake.switchIDs, wantSwitch)
|
||||
}
|
||||
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
|
||||
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db,
|
||||
userBaseInfoRow{ID: 1, Account: "1", UserNickname: "manager", OriginSys: "LIKEI"},
|
||||
userBaseInfoRow{ID: 2, Account: "2", OriginSys: "LIKEI"},
|
||||
)
|
||||
seedVIPConfigs(t, db, model.VipLevelConfig{
|
||||
ID: 301,
|
||||
SysOrigin: "LIKEI",
|
||||
Level: 3,
|
||||
LevelCode: "VIP3",
|
||||
DisplayName: "VIP 3",
|
||||
Enabled: true,
|
||||
DurationDays: 30,
|
||||
PriceGold: 3000,
|
||||
})
|
||||
|
||||
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
|
||||
AcceptUserID: ID(2),
|
||||
PropsID: ID(301),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendProps() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 {
|
||||
t.Fatalf("resp = %+v, want vip gift success", resp)
|
||||
}
|
||||
|
||||
var state model.UserVipState
|
||||
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
|
||||
t.Fatalf("load state: %v", err)
|
||||
}
|
||||
if state.Level != 3 || state.Status != vipStatusActive {
|
||||
t.Fatalf("state = %+v, want vip3 active", state)
|
||||
}
|
||||
var orders int64
|
||||
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
|
||||
t.Fatalf("count orders: %v", err)
|
||||
}
|
||||
if orders != 1 {
|
||||
t.Fatalf("orders = %d, want 1", orders)
|
||||
}
|
||||
var records int64
|
||||
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil {
|
||||
t.Fatalf("count gift records: %v", err)
|
||||
}
|
||||
if records != 1 {
|
||||
t.Fatalf("gift records = %d, want 1", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserRejectsRechargedUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
fake.recharges[2] = []integration.UserTotalRecharge{{Amount: integration.DecimalString("1.00")}}
|
||||
|
||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "user_recharged")
|
||||
if len(fake.punishRequests) != 0 {
|
||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserFailsWhenRechargeQueryUnavailable(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
fake.rechargeErr = errors.New("order service unavailable")
|
||||
|
||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "recharge_query_failed")
|
||||
if len(fake.punishRequests) != 0 {
|
||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserRejectsArchivedUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
||||
|
||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "user_already_banned")
|
||||
if len(fake.punishRequests) != 0 {
|
||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserRejectsFrozenUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusFreeze})
|
||||
|
||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "user_already_banned")
|
||||
if len(fake.punishRequests) != 0 {
|
||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
|
||||
resp, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{
|
||||
UserID: ID(2),
|
||||
Reason: "spam",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BanUser() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.AccountStatus != accountStatusArchive {
|
||||
t.Fatalf("resp = %+v, want archived success", resp)
|
||||
}
|
||||
if len(fake.punishRequests) != 1 {
|
||||
t.Fatalf("punish requests = %+v, want one", fake.punishRequests)
|
||||
}
|
||||
got := fake.punishRequests[0]
|
||||
if got.BeOptUserID != 2 || got.OptUserID != 1 || got.Status != accountStatusArchive {
|
||||
t.Fatalf("punish request = %+v, want target 2 manager 1 archive", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanUserRejectsNormalUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusNormal})
|
||||
|
||||
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "user_not_banned")
|
||||
if len(fake.unblockRequests) != 0 {
|
||||
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanUserCallsUnblockAccount(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
||||
|
||||
resp, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{
|
||||
UserID: ID(2),
|
||||
Reason: "appeal passed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UnbanUser() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.AccountStatus != accountStatusNormal {
|
||||
t.Fatalf("resp = %+v, want normal success", resp)
|
||||
}
|
||||
if len(fake.unblockRequests) != 1 {
|
||||
t.Fatalf("unblock requests = %+v, want one", fake.unblockRequests)
|
||||
}
|
||||
got := fake.unblockRequests[0]
|
||||
if got.BeOptUserID != 2 || got.OptUserID != 1 {
|
||||
t.Fatalf("unblock request = %+v, want target 2 manager 1", got)
|
||||
}
|
||||
if got.Description == "" || !strings.Contains(got.Description, "appeal passed") {
|
||||
t.Fatalf("description = %q, want reason included", got.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanUserAllowsFrozenUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusFreeze})
|
||||
|
||||
resp, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
||||
if err != nil {
|
||||
t.Fatalf("UnbanUser() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.AccountStatus != accountStatusNormal {
|
||||
t.Fatalf("resp = %+v, want normal success", resp)
|
||||
}
|
||||
if len(fake.unblockRequests) != 1 {
|
||||
t.Fatalf("unblock requests = %+v, want one", fake.unblockRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&administratorRow{},
|
||||
&userBaseInfoRow{},
|
||||
&propsSourceRecordRow{},
|
||||
&propsNobleVIPAbilityRow{},
|
||||
&model.VipLevelConfig{},
|
||||
&model.UserVipState{},
|
||||
&model.VipOrderRecord{},
|
||||
&gmVIPGiftRecordRow{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
fake := &fakeManagerGateway{
|
||||
recharges: map[int64][]integration.UserTotalRecharge{},
|
||||
abilities: map[int64]integration.PropsNobleVIPAbility{},
|
||||
}
|
||||
return NewService(db, fake), fake
|
||||
}
|
||||
|
||||
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
||||
t.Helper()
|
||||
if err := db.Create(&administratorRow{ID: userID, UserID: userID, Status: true}).Error; err != nil {
|
||||
t.Fatalf("seed manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if row.AccountStatus == "" {
|
||||
row.AccountStatus = "ACTIVE"
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed props: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedAbilities(t *testing.T, db *gorm.DB, rows ...propsNobleVIPAbilityRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed ability: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedVIPConfigs(t *testing.T, db *gorm.DB, rows ...model.VipLevelConfig) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
for _, row := range rows {
|
||||
if row.CreateTime.IsZero() {
|
||||
row.CreateTime = now
|
||||
}
|
||||
if row.UpdateTime.IsZero() {
|
||||
row.UpdateTime = now
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed vip config: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAppErrorCode(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("error is nil, want code %s", code)
|
||||
}
|
||||
var appErr *AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("error = %T %v, want AppError", err, err)
|
||||
}
|
||||
if appErr.Code != code {
|
||||
t.Fatalf("error code = %s, want %s", appErr.Code, code)
|
||||
}
|
||||
}
|
||||
|
||||
func equalInt64Slice(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
202
internal/service/managercenter/types.go
Normal file
202
internal/service/managercenter/types.go
Normal file
@ -0,0 +1,202 @@
|
||||
package managercenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/integration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
propsTypeNobleVIP = "NOBLE_VIP"
|
||||
propsTypeRide = "RIDE"
|
||||
propsTypeAvatarFrame = "AVATAR_FRAME"
|
||||
propsTypeChatBubble = "CHAT_BUBBLE"
|
||||
propsTypeDataCard = "DATA_CARD"
|
||||
propsTypeBadge = "BADGE"
|
||||
propsTypeFloatPicture = "FLOAT_PICTURE"
|
||||
|
||||
adminFreeOrigin = "ADMIN_FREE"
|
||||
adminFreeOriginDesc = "Admin free"
|
||||
vipGiftOrigin = "VIP_PURCHASE"
|
||||
vipGiftOriginDesc = "VIP purchase"
|
||||
|
||||
defaultGiftDays = 7
|
||||
defaultVIPGiftDays = 30
|
||||
|
||||
accountStatusNormal = "NORMAL"
|
||||
accountStatusFreeze = "FREEZE"
|
||||
accountStatusArchive = "ARCHIVE"
|
||||
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
|
||||
)
|
||||
|
||||
type AppError = common.AppError
|
||||
type AuthUser = common.AuthUser
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
|
||||
type managerDB interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
type managerGateway interface {
|
||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
|
||||
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
||||
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
||||
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
|
||||
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
|
||||
SwitchUseProps(ctx context.Context, userID int64, propsID int64) error
|
||||
ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error
|
||||
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
|
||||
PunishAccount(ctx context.Context, req integration.PunishAccountRequest) error
|
||||
UnblockAccount(ctx context.Context, req integration.UnblockAccountRequest) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db managerDB
|
||||
java managerGateway
|
||||
}
|
||||
|
||||
func NewService(db managerDB, java managerGateway) *Service {
|
||||
return &Service{db: db, java: java}
|
||||
}
|
||||
|
||||
type ID int64
|
||||
|
||||
func (id ID) Int64() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id ID) MarshalJSON() ([]byte, error) {
|
||||
return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil
|
||||
}
|
||||
|
||||
func (id *ID) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" || raw == "null" {
|
||||
*id = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "\"") {
|
||||
var parsed string
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(parsed)
|
||||
}
|
||||
if raw == "" {
|
||||
*id = 0
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = ID(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Manager UserView `json:"manager"`
|
||||
}
|
||||
|
||||
type UserSearchResponse struct {
|
||||
User UserView `json:"user"`
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
UserID ID `json:"userId"`
|
||||
Account string `json:"account"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
CountryCode string `json:"countryCode,omitempty"`
|
||||
CountryName string `json:"countryName,omitempty"`
|
||||
OriginSys string `json:"originSys,omitempty"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
HasRecharge bool `json:"hasRecharge"`
|
||||
CanBan bool `json:"canBan"`
|
||||
}
|
||||
|
||||
type PropsListResponse struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Records []PropsView `json:"records"`
|
||||
}
|
||||
|
||||
type PropsView struct {
|
||||
ID ID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
Expand string `json:"expand,omitempty"`
|
||||
Days int `json:"days"`
|
||||
VIPLevel int `json:"vipLevel,omitempty"`
|
||||
}
|
||||
|
||||
type SendPropsRequest struct {
|
||||
AcceptUserID ID `json:"acceptUserId"`
|
||||
PropsID ID `json:"propsId"`
|
||||
}
|
||||
|
||||
type SendPropsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
AcceptUserID ID `json:"acceptUserId"`
|
||||
PropsID ID `json:"propsId"`
|
||||
Type string `json:"type"`
|
||||
Days int `json:"days"`
|
||||
VIPLevel int `json:"vipLevel,omitempty"`
|
||||
}
|
||||
|
||||
type BanUserRequest struct {
|
||||
UserID ID `json:"userId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type BanUserResponse struct {
|
||||
Success bool `json:"success"`
|
||||
UserID ID `json:"userId"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
}
|
||||
|
||||
type UnbanUserRequest struct {
|
||||
UserID ID `json:"userId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type UnbanUserResponse struct {
|
||||
Success bool `json:"success"`
|
||||
UserID ID `json:"userId"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
}
|
||||
|
||||
func badRequest(code, message string) error {
|
||||
return NewAppError(http.StatusBadRequest, code, message)
|
||||
}
|
||||
|
||||
func unauthorized(code, message string) error {
|
||||
return NewAppError(http.StatusUnauthorized, code, message)
|
||||
}
|
||||
|
||||
func forbidden(code, message string) error {
|
||||
return NewAppError(http.StatusForbidden, code, message)
|
||||
}
|
||||
|
||||
func notFound(code, message string) error {
|
||||
return NewAppError(http.StatusNotFound, code, message)
|
||||
}
|
||||
|
||||
func serviceUnavailable(code, message string) error {
|
||||
return NewAppError(http.StatusServiceUnavailable, code, message)
|
||||
}
|
||||
|
||||
func serverError(code, message string) error {
|
||||
return NewAppError(http.StatusInternalServerError, code, message)
|
||||
}
|
||||
425
internal/service/managercenter/vip_gift.go
Normal file
425
internal/service/managercenter/vip_gift.go
Normal file
@ -0,0 +1,425 @@
|
||||
package managercenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
vipStatusActive = "ACTIVE"
|
||||
vipStatusSuccess = "SUCCESS"
|
||||
vipOrderTypeGMGift = "GM_GIFT"
|
||||
vipGiftEventPrefix = "MANAGER-VIP-GIFT-"
|
||||
mysqlTimestampMaxYear = 2038
|
||||
)
|
||||
|
||||
type gmVIPGiftRecordRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
SysOrigin string `gorm:"column:sys_origin"`
|
||||
FromLevel int `gorm:"column:from_level"`
|
||||
VIPLevel int `gorm:"column:vip_level"`
|
||||
LevelCode string `gorm:"column:level_code"`
|
||||
DisplayName string `gorm:"column:display_name"`
|
||||
DurationDays int `gorm:"column:duration_days"`
|
||||
PriceGold int64 `gorm:"column:price_gold"`
|
||||
StartAt time.Time `gorm:"column:start_at"`
|
||||
ExpireAt time.Time `gorm:"column:expire_at"`
|
||||
OrderID int64 `gorm:"column:order_id"`
|
||||
EventID string `gorm:"column:event_id"`
|
||||
Status string `gorm:"column:status"`
|
||||
ApplicantID int64 `gorm:"column:applicant_id"`
|
||||
ApplicantName string `gorm:"column:applicant_name"`
|
||||
Remarks string `gorm:"column:remarks"`
|
||||
ExecutedTime time.Time `gorm:"column:executed_time"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
CreateUser int64 `gorm:"column:create_user"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (gmVIPGiftRecordRow) TableName() string {
|
||||
return "gm_vip_gift_record"
|
||||
}
|
||||
|
||||
type vipGiftResource struct {
|
||||
resourceID int64
|
||||
propsType string
|
||||
badge bool
|
||||
}
|
||||
|
||||
func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListResponse, error) {
|
||||
sysOrigin := normalizeSysOrigin(user.SysOrigin)
|
||||
var rows []model.VipLevelConfig
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND enabled = ?", sysOrigin, true).
|
||||
Order("level ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, serverError("vip_config_query_failed", err.Error())
|
||||
}
|
||||
|
||||
records := make([]PropsView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
records = append(records, vipPropsViewFromConfig(row))
|
||||
}
|
||||
return &PropsListResponse{
|
||||
Type: propsTypeNobleVIP,
|
||||
Records: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) {
|
||||
sysOrigin := normalizeSysOrigin(target.OriginSys)
|
||||
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
if !config.Enabled {
|
||||
return nil, badRequest("vip_level_disabled", "vip level is disabled")
|
||||
}
|
||||
if config.Level < 1 || config.Level > 5 {
|
||||
return nil, badRequest("vip_not_giftable", "vip level is invalid")
|
||||
}
|
||||
if config.DurationDays <= 0 {
|
||||
config.DurationDays = defaultVIPGiftDays
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
orderID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, serverError("id_generate_failed", err.Error())
|
||||
}
|
||||
recordID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, serverError("id_generate_failed", err.Error())
|
||||
}
|
||||
stateID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, serverError("id_generate_failed", err.Error())
|
||||
}
|
||||
eventID := fmt.Sprintf("%s%d", vipGiftEventPrefix, orderID)
|
||||
applicantName := s.managerApplicantName(ctx, manager.UserID)
|
||||
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, serverError("vip_gift_failed", tx.Error.Error())
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback().Error
|
||||
}
|
||||
}()
|
||||
|
||||
state, err := s.loadLockedVIPState(tx, sysOrigin, target.ID)
|
||||
if err != nil {
|
||||
return nil, serverError("vip_state_query_failed", err.Error())
|
||||
}
|
||||
currentActive := isActiveVIPState(state, now)
|
||||
fromLevel := 0
|
||||
if currentActive {
|
||||
fromLevel = state.Level
|
||||
}
|
||||
if currentActive && fromLevel > config.Level {
|
||||
return nil, badRequest("vip_downgrade_not_allowed", "vip downgrade is not allowed")
|
||||
}
|
||||
|
||||
startAt := now
|
||||
if currentActive && !state.StartAt.IsZero() {
|
||||
startAt = state.StartAt
|
||||
}
|
||||
expireAt := resolveVIPGiftExpireAt(state, config, currentActive, now)
|
||||
|
||||
order := model.VipOrderRecord{
|
||||
ID: orderID,
|
||||
EventID: eventID,
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: target.ID,
|
||||
OrderType: vipOrderTypeGMGift,
|
||||
FromLevel: fromLevel,
|
||||
ToLevel: config.Level,
|
||||
DurationDays: config.DurationDays,
|
||||
OriginalPriceGold: config.PriceGold,
|
||||
PaidGold: 0,
|
||||
Status: vipStatusSuccess,
|
||||
StartAt: startAt,
|
||||
ExpireAt: expireAt,
|
||||
ErrorMessage: "",
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return nil, serverError("vip_order_create_failed", err.Error())
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
state = &model.UserVipState{
|
||||
ID: stateID,
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: target.ID,
|
||||
CreateTime: now,
|
||||
}
|
||||
}
|
||||
applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
|
||||
if err := tx.Save(state).Error; err != nil {
|
||||
return nil, serverError("vip_state_save_failed", err.Error())
|
||||
}
|
||||
|
||||
record := gmVIPGiftRecordRow{
|
||||
ID: recordID,
|
||||
UserID: target.ID,
|
||||
SysOrigin: sysOrigin,
|
||||
FromLevel: fromLevel,
|
||||
VIPLevel: config.Level,
|
||||
LevelCode: vipLevelCode(config),
|
||||
DisplayName: vipDisplayName(config),
|
||||
DurationDays: config.DurationDays,
|
||||
PriceGold: config.PriceGold,
|
||||
StartAt: startAt,
|
||||
ExpireAt: expireAt,
|
||||
OrderID: orderID,
|
||||
EventID: eventID,
|
||||
Status: vipStatusSuccess,
|
||||
ApplicantID: manager.UserID,
|
||||
ApplicantName: applicantName,
|
||||
Remarks: fmt.Sprintf("manager center gift, manager=%d", manager.UserID),
|
||||
ExecutedTime: now,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
CreateUser: manager.UserID,
|
||||
UpdateUser: manager.UserID,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return nil, serverError("vip_gift_record_create_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, serverError("vip_gift_failed", err.Error())
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil {
|
||||
return nil, serverError("vip_resource_grant_failed", err.Error())
|
||||
}
|
||||
|
||||
return &SendPropsResponse{
|
||||
Success: true,
|
||||
AcceptUserID: ID(target.ID),
|
||||
PropsID: ID(config.ID),
|
||||
Type: propsTypeNobleVIP,
|
||||
Days: config.DurationDays,
|
||||
VIPLevel: config.Level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadVIPConfigByID(ctx context.Context, sysOrigin string, configID int64) (model.VipLevelConfig, bool, error) {
|
||||
var row model.VipLevelConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id = ? AND sys_origin = ?", configID, sysOrigin).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.VipLevelConfig{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.VipLevelConfig{}, false, serverError("vip_config_query_failed", err.Error())
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadLockedVIPState(tx *gorm.DB, sysOrigin string, userID int64) (*model.UserVipState, error) {
|
||||
var row model.UserVipState
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func isActiveVIPState(state *model.UserVipState, now time.Time) bool {
|
||||
return state != nil && state.Status == vipStatusActive && state.Level > 0 && state.ExpireAt.After(now)
|
||||
}
|
||||
|
||||
func resolveVIPGiftExpireAt(state *model.UserVipState, config model.VipLevelConfig, currentActive bool, now time.Time) time.Time {
|
||||
durationDays := config.DurationDays
|
||||
if durationDays <= 0 {
|
||||
durationDays = defaultVIPGiftDays
|
||||
}
|
||||
plusDuration := now.AddDate(0, 0, durationDays)
|
||||
if !currentActive {
|
||||
return plusDuration
|
||||
}
|
||||
if state.Level == config.Level {
|
||||
return maxTime(state.ExpireAt, now).AddDate(0, 0, durationDays)
|
||||
}
|
||||
return maxTime(state.ExpireAt, plusDuration)
|
||||
}
|
||||
|
||||
func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelConfig, startAt time.Time, expireAt time.Time, now time.Time) {
|
||||
state.Level = config.Level
|
||||
state.LevelCode = vipLevelCode(config)
|
||||
state.DisplayName = vipDisplayName(config)
|
||||
state.Status = vipStatusActive
|
||||
state.StartAt = startAt
|
||||
state.ExpireAt = expireAt
|
||||
state.BadgeResourceID = config.BadgeResourceID
|
||||
state.BadgeName = config.BadgeName
|
||||
state.BadgeURL = config.BadgeURL
|
||||
state.AvatarFrameResourceID = config.AvatarFrameResourceID
|
||||
state.AvatarFrameName = config.AvatarFrameName
|
||||
state.AvatarFrameURL = config.AvatarFrameURL
|
||||
state.EntryEffectResourceID = config.EntryEffectResourceID
|
||||
state.EntryEffectName = config.EntryEffectName
|
||||
state.EntryEffectURL = config.EntryEffectURL
|
||||
state.ChatBubbleResourceID = config.ChatBubbleResourceID
|
||||
state.ChatBubbleName = config.ChatBubbleName
|
||||
state.ChatBubbleURL = config.ChatBubbleURL
|
||||
state.FloatPictureResourceID = config.FloatPictureResourceID
|
||||
state.FloatPictureName = config.FloatPictureName
|
||||
state.FloatPictureURL = config.FloatPictureURL
|
||||
state.UpdateTime = now
|
||||
if state.CreateTime.IsZero() {
|
||||
state.CreateTime = now
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
|
||||
resources := vipGiftResourcesFromConfig(config)
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
days := config.DurationDays
|
||||
if days <= 0 {
|
||||
days = defaultVIPGiftDays
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.badge {
|
||||
if err := s.java.ActivateTemporaryBadge(ctx, userID, resource.resourceID, days); err != nil {
|
||||
return fmt.Errorf("grant vip badge %d: %w", resource.resourceID, err)
|
||||
}
|
||||
} else {
|
||||
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
|
||||
AcceptUserID: userID,
|
||||
PropsID: resource.resourceID,
|
||||
Type: resource.propsType,
|
||||
Origin: vipGiftOrigin,
|
||||
OriginDesc: vipGiftOriginDesc,
|
||||
Days: days,
|
||||
UseProps: true,
|
||||
AllowGive: false,
|
||||
OpUser: managerID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("grant vip resource %s:%d: %w", resource.propsType, resource.resourceID, err)
|
||||
}
|
||||
if err := s.java.SwitchUseProps(ctx, userID, resource.resourceID); err != nil {
|
||||
return fmt.Errorf("switch vip resource %s:%d: %w", resource.propsType, resource.resourceID, err)
|
||||
}
|
||||
}
|
||||
if err := s.alignVIPGiftResourceExpireAt(ctx, userID, resource, expireAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
|
||||
return fmt.Errorf("remove vip user profile cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) alignVIPGiftResourceExpireAt(ctx context.Context, userID int64, resource vipGiftResource, expireAt time.Time, now time.Time) error {
|
||||
expireAt = capMySQLTimestamp(expireAt)
|
||||
var result *gorm.DB
|
||||
if resource.badge {
|
||||
result = s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id = ?",
|
||||
"TEMPORARY", expireAt, true, now, userID, resource.resourceID,
|
||||
)
|
||||
} else {
|
||||
result = s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id = ? AND type = ?",
|
||||
expireAt, true, false, now, userID, resource.resourceID, resource.propsType,
|
||||
)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("vip resource backpack row not found after grant: %d", resource.resourceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
|
||||
resources := []vipGiftResource{
|
||||
{resourceID: config.BadgeResourceID, propsType: propsTypeBadge, badge: true},
|
||||
{resourceID: config.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
|
||||
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
|
||||
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
|
||||
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
|
||||
}
|
||||
out := make([]vipGiftResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.resourceID > 0 {
|
||||
out = append(out, resource)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
|
||||
row, err := s.loadUserRowByID(ctx, managerID)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%d", managerID)
|
||||
}
|
||||
if strings.TrimSpace(row.UserNickname) != "" {
|
||||
return row.UserNickname
|
||||
}
|
||||
if strings.TrimSpace(row.Account) != "" {
|
||||
return row.Account
|
||||
}
|
||||
return fmt.Sprintf("%d", managerID)
|
||||
}
|
||||
|
||||
func vipLevelCode(config model.VipLevelConfig) string {
|
||||
code := strings.TrimSpace(config.LevelCode)
|
||||
if code == "" {
|
||||
return fmt.Sprintf("VIP%d", config.Level)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func vipDisplayName(config model.VipLevelConfig) string {
|
||||
name := strings.TrimSpace(config.DisplayName)
|
||||
if name == "" {
|
||||
return fmt.Sprintf("VIP %d", config.Level)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func maxTime(a time.Time, b time.Time) time.Time {
|
||||
if a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func capMySQLTimestamp(value time.Time) time.Time {
|
||||
maxValue := time.Date(mysqlTimestampMaxYear, time.January, 19, 0, 0, 0, 0, time.Local)
|
||||
if value.After(maxValue) {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -25,6 +25,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI
|
||||
return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing")
|
||||
}
|
||||
|
||||
if s != nil && s.regionResolver != nil {
|
||||
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
if err == nil {
|
||||
if normalized := normalizeRegionCode(regionCode); normalized != "" {
|
||||
return normalized, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mapping model.VoiceRoomRegionCountry
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).
|
||||
|
||||
@ -133,6 +133,42 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
|
||||
service, db := newRegionIMGroupTestService(t)
|
||||
service.regionResolver = fakeRegionResolver{
|
||||
regions: map[string]string{"LIKEI|SA": "中东区"},
|
||||
}
|
||||
fakeIM := &fakeIMGateway{}
|
||||
service.im = fakeIM
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.VoiceRoomRegionIMGroup{
|
||||
ID: 3003,
|
||||
SysOrigin: "LIKEI",
|
||||
RegionCode: "中东区",
|
||||
RegionName: "Middle East",
|
||||
GroupID: "@region-middle-east",
|
||||
GroupName: "region-middle-east",
|
||||
Status: imGroupStatusActive,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed im group: %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
SenderUserID: flexibleInt64(1001),
|
||||
Type: "SEND_GIFT",
|
||||
Data: map[string]any{"roomId": "9001"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendRegionBroadcast() error = %v", err)
|
||||
}
|
||||
if resp.RegionCode != "中东区" || resp.GroupID != "@region-middle-east" {
|
||||
t.Fatalf("SendRegionBroadcast() response = %+v, want Java region group", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegionIMGroupTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
@ -191,3 +227,16 @@ func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string
|
||||
g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body})
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRegionResolver struct {
|
||||
regions map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
if g.err != nil {
|
||||
return "", g.err
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
return strings.TrimSpace(g.regions[key]), nil
|
||||
}
|
||||
|
||||
@ -36,18 +36,28 @@ type imGateway interface {
|
||||
SendCustomGroupMessage(ctx context.Context, groupID string, body any) error
|
||||
}
|
||||
|
||||
// Service 负责区域通用 IM 群组创建、查询和区域广播。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db dbHandle
|
||||
im imGateway
|
||||
type regionResolver interface {
|
||||
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db dbHandle) *Service {
|
||||
// Service 负责区域通用 IM 群组创建、查询和区域广播。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db dbHandle
|
||||
im imGateway
|
||||
regionResolver regionResolver
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db dbHandle, resolvers ...regionResolver) *Service {
|
||||
var resolver regionResolver
|
||||
if len(resolvers) > 0 {
|
||||
resolver = resolvers[0]
|
||||
}
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
im: tencentim.NewClient(cfg.TencentIM),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
im: tencentim.NewClient(cfg.TencentIM),
|
||||
regionResolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -233,45 +233,6 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCa
|
||||
return &PageResponse[ClaimRecordView]{Records: records, Total: total, Current: cursor, Size: limit}, nil
|
||||
}
|
||||
|
||||
// PageEvents 分页查询事件流水。
|
||||
func (s *Service) PageEvents(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, eventType string, cursor int, limit int) (*PageResponse[EventView], error) {
|
||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||
category := normalizeCategory(taskCategory)
|
||||
if err := validateCategory(category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cursor, limit = NormalizePage(cursor, limit)
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}).
|
||||
Where("sys_origin = ? AND task_category = ?", sysOrigin, category)
|
||||
if userID > 0 {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" {
|
||||
query = query.Where("task_code = ?", code)
|
||||
}
|
||||
if normalizedEventType := strings.ToUpper(strings.TrimSpace(eventType)); normalizedEventType != "" {
|
||||
query = query.Where("event_type = ?", normalizedEventType)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []model.TaskCenterEvent
|
||||
if err := query.Order("create_time desc, id desc").
|
||||
Offset((cursor - 1) * limit).
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := make([]EventView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
records = append(records, eventView(row))
|
||||
}
|
||||
return &PageResponse[EventView]{Records: records, Total: total, Current: cursor, Size: limit}, nil
|
||||
}
|
||||
|
||||
func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "tasks is required")
|
||||
@ -638,25 +599,6 @@ func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView {
|
||||
}
|
||||
}
|
||||
|
||||
func eventView(row model.TaskCenterEvent) EventView {
|
||||
return EventView{
|
||||
ID: row.ID,
|
||||
SysOrigin: row.SysOrigin,
|
||||
EventID: row.EventID,
|
||||
EventType: row.EventType,
|
||||
UserID: row.UserID,
|
||||
TaskID: row.TaskID,
|
||||
TaskCode: row.TaskCode,
|
||||
TaskCategory: row.TaskCategory,
|
||||
CycleKey: row.CycleKey,
|
||||
DeltaValue: row.DeltaValue,
|
||||
CompletedValue: row.CompletedValue,
|
||||
OccurredAt: formatDateTime(row.OccurredAt),
|
||||
CreateTime: formatDateTime(row.CreateTime),
|
||||
UpdateTime: formatDateTime(row.UpdateTime),
|
||||
}
|
||||
}
|
||||
|
||||
func taskCenterSysOriginBySavedID(ctx context.Context, db taskCenterDB, configID int64, fallback string) string {
|
||||
if configID <= 0 {
|
||||
return fallback
|
||||
|
||||
273
internal/service/taskcenter/archive.go
Normal file
273
internal/service/taskcenter/archive.go
Normal file
@ -0,0 +1,273 @@
|
||||
package taskcenter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"github.com/tencentyun/cos-go-sdk-v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
|
||||
func (s *Service) StartArchiveWorker(ctx context.Context) error {
|
||||
if !s.cfg.TaskCenter.Archive.Enabled {
|
||||
log.Printf("task center archive worker disabled")
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.Region) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.SecretID) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.SecretKey) == "" {
|
||||
log.Printf("task center archive worker skipped: cos bucket, region or credentials missing")
|
||||
return nil
|
||||
}
|
||||
|
||||
workers := s.cfg.TaskCenter.Archive.Workers
|
||||
if workers <= 0 {
|
||||
workers = 1
|
||||
}
|
||||
for index := 0; index < workers; index++ {
|
||||
go s.runArchiveWorker(ctx, index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) runArchiveWorker(ctx context.Context, workerIndex int) {
|
||||
interval := time.Duration(s.cfg.TaskCenter.Archive.FlushSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.archivePendingEvents(ctx); err != nil {
|
||||
log.Printf("task center archive worker %d failed: %v", workerIndex, err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) archivePendingEvents(ctx context.Context) error {
|
||||
batchSize := s.cfg.TaskCenter.Archive.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 10000
|
||||
}
|
||||
rows, err := s.claimArchiveRows(ctx, batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
client, err := s.newCOSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, group := range groupArchiveRows(rows) {
|
||||
if err := s.uploadArchiveGroup(ctx, client, group); err != nil {
|
||||
_ = s.markArchiveGroupFailed(ctx, group, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.TaskCenterEventArchiveOutbox, error) {
|
||||
var rows []model.TaskCenterEventArchiveOutbox
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("status IN ? OR (status = ? AND update_time < ?)",
|
||||
[]string{ArchiveStatusPending, ArchiveStatusFailed},
|
||||
ArchiveStatusProcessing,
|
||||
staleProcessingBefore,
|
||||
).
|
||||
Order("create_time asc, id asc").
|
||||
Limit(batchSize).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.TaskCenterEventArchiveOutbox{}).
|
||||
Where("id IN ?", archiveRowIDs(rows)).
|
||||
Updates(map[string]any{
|
||||
"status": ArchiveStatusProcessing,
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Service) newCOSClient() (*cos.Client, error) {
|
||||
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.Region),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL := &cos.BaseURL{BucketURL: bucketURL}
|
||||
return cos.NewClient(baseURL, &http.Client{
|
||||
Transport: &cos.AuthorizationTransport{
|
||||
SecretID: strings.TrimSpace(s.cfg.TaskCenter.Archive.SecretID),
|
||||
SecretKey: strings.TrimSpace(s.cfg.TaskCenter.Archive.SecretKey),
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
func groupArchiveRows(rows []model.TaskCenterEventArchiveOutbox) [][]model.TaskCenterEventArchiveOutbox {
|
||||
groupsByKey := make(map[string][]model.TaskCenterEventArchiveOutbox)
|
||||
keys := make([]string, 0)
|
||||
for _, row := range rows {
|
||||
groupKey := row.SysOrigin + "\x00" + row.EventType
|
||||
if _, exists := groupsByKey[groupKey]; !exists {
|
||||
keys = append(keys, groupKey)
|
||||
}
|
||||
groupsByKey[groupKey] = append(groupsByKey[groupKey], row)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
groups := make([][]model.TaskCenterEventArchiveOutbox, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
groups = append(groups, groupsByKey[key])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (s *Service) uploadArchiveGroup(ctx context.Context, client *cos.Client, rows []model.TaskCenterEventArchiveOutbox) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
cosKey := s.buildArchiveCOSKey(rows)
|
||||
body, err := gzipArchiveRows(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Object.Put(ctx, cosKey, bytes.NewReader(body), &cos.ObjectPutOptions{
|
||||
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
|
||||
ContentType: "application/gzip",
|
||||
ContentEncoding: "gzip",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.markArchiveGroupUploaded(ctx, rows, cosKey)
|
||||
}
|
||||
|
||||
func gzipArchiveRows(rows []model.TaskCenterEventArchiveOutbox) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
writer := gzip.NewWriter(&buffer)
|
||||
for _, row := range rows {
|
||||
line := strings.TrimSpace(row.RawJSON)
|
||||
if line == "" {
|
||||
line = "{}"
|
||||
}
|
||||
if _, err := writer.Write([]byte(line)); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := writer.Write([]byte("\n")); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *Service) buildArchiveCOSKey(rows []model.TaskCenterEventArchiveOutbox) string {
|
||||
first := rows[0]
|
||||
occurredAt := first.OccurredAt
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
occurredAt = occurredAt.UTC()
|
||||
prefix := strings.Trim(strings.TrimSpace(s.cfg.TaskCenter.Archive.Prefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = "task-center-events/v1"
|
||||
}
|
||||
fileName := fmt.Sprintf("part-%s-%d-%d.jsonl.gz",
|
||||
time.Now().UTC().Format("20060102T150405.000000000Z"),
|
||||
first.ID,
|
||||
len(rows),
|
||||
)
|
||||
return path.Join(
|
||||
prefix,
|
||||
"dt="+occurredAt.Format("2006-01-02"),
|
||||
"hour="+occurredAt.Format("15"),
|
||||
"sys_origin="+sanitizeArchivePartition(first.SysOrigin),
|
||||
"event_type="+sanitizeArchivePartition(first.EventType),
|
||||
fileName,
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeArchivePartition(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_", "=", "_")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func (s *Service) markArchiveGroupUploaded(ctx context.Context, rows []model.TaskCenterEventArchiveOutbox, cosKey string) error {
|
||||
ids := archiveRowIDs(rows)
|
||||
now := time.Now()
|
||||
return s.db.WithContext(ctx).Model(&model.TaskCenterEventArchiveOutbox{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(map[string]any{
|
||||
"status": ArchiveStatusUploaded,
|
||||
"cos_key": cosKey,
|
||||
"failure_reason": "",
|
||||
"archived_at": now,
|
||||
"update_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Service) markArchiveGroupFailed(ctx context.Context, rows []model.TaskCenterEventArchiveOutbox, archiveErr error) error {
|
||||
ids := archiveRowIDs(rows)
|
||||
return s.db.WithContext(ctx).Model(&model.TaskCenterEventArchiveOutbox{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(map[string]any{
|
||||
"status": ArchiveStatusFailed,
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
"failure_reason": truncateTaskCenterFailure(archiveErr.Error()),
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func archiveRowIDs(rows []model.TaskCenterEventArchiveOutbox) []int64 {
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReportSimpleEvent 供 Go 内部业务模块上报任务进度事件。
|
||||
@ -23,7 +24,7 @@ func (s *Service) ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eve
|
||||
|
||||
// ReportSimpleEventAt 和 ReportSimpleEvent 相同,但允许 MQ 等异步事件传入真实发生时间。
|
||||
func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt time.Time, payload map[string]any) error {
|
||||
_, err := s.ReceiveEvent(ctx, EventRequest{
|
||||
_, err := s.ProcessEvent(ctx, EventRequest{
|
||||
SysOrigin: sysOrigin,
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
@ -40,6 +41,11 @@ func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, e
|
||||
|
||||
// ReceiveEvent 接收后端业务事件并更新命中的任务进度。
|
||||
func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventResponse, error) {
|
||||
return s.ProcessEvent(ctx, req)
|
||||
}
|
||||
|
||||
// ProcessEvent 统一处理 HTTP 和 MQ 上报的任务事件。
|
||||
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventResponse, error) {
|
||||
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
|
||||
eventID := strings.TrimSpace(req.EventID)
|
||||
if eventID == "" {
|
||||
@ -59,55 +65,50 @@ func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventRes
|
||||
if req.DeltaValue <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_delta_value", "deltaValue must be greater than 0")
|
||||
}
|
||||
|
||||
var existingCount int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}).
|
||||
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
|
||||
Count(&existingCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existingCount > 0 {
|
||||
return &EventResponse{Processed: false, Updated: 0, Reason: "duplicate_event"}, nil
|
||||
}
|
||||
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
||||
receivedAt := time.Now()
|
||||
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
||||
|
||||
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bundle == nil {
|
||||
return &EventResponse{Processed: false, Updated: 0, Reason: "task_center_disabled"}, nil
|
||||
}
|
||||
|
||||
matchedTasks := make([]model.TaskCenterTaskConfig, 0)
|
||||
for _, task := range bundle.Tasks {
|
||||
if strings.EqualFold(task.ConditionType, eventType) {
|
||||
matchedTasks = append(matchedTasks, task)
|
||||
if bundle != nil {
|
||||
for _, task := range bundle.Tasks {
|
||||
if strings.EqualFold(task.ConditionType, eventType) {
|
||||
matchedTasks = append(matchedTasks, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matchedTasks) == 0 {
|
||||
return &EventResponse{Processed: false, Updated: 0, Reason: "no_matched_task"}, nil
|
||||
}
|
||||
|
||||
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
||||
rawJSON := ""
|
||||
if len(req.Payload) > 0 {
|
||||
if bytes, marshalErr := json.Marshal(req.Payload); marshalErr == nil {
|
||||
rawJSON = string(bytes)
|
||||
}
|
||||
resultReason := ""
|
||||
if bundle == nil {
|
||||
resultReason = "task_center_disabled"
|
||||
} else if len(matchedTasks) == 0 {
|
||||
resultReason = "no_matched_task"
|
||||
}
|
||||
|
||||
updated := 0
|
||||
duplicate := false
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var duplicateCount int64
|
||||
if err := tx.Model(&model.TaskCenterEvent{}).
|
||||
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
|
||||
Count(&duplicateCount).Error; err != nil {
|
||||
inserted, err := insertTaskEventDedupTx(tx, sysOrigin, eventID, eventType, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicateCount > 0 {
|
||||
if !inserted {
|
||||
duplicate = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := createTaskEventArchiveOutboxTx(tx, sysOrigin, eventID, eventType, userID, occurredAt, rawJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
if resultReason != "" {
|
||||
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusIgnored, resultReason)
|
||||
}
|
||||
|
||||
for _, task := range matchedTasks {
|
||||
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, occurredAt)
|
||||
progress, err := s.loadOrCreateProgressTx(tx, sysOrigin, userID, task, cycleKey)
|
||||
@ -128,43 +129,114 @@ func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventRes
|
||||
if err := tx.Save(&progress).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventRowID, idErr := utils.NextID()
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
eventRow := model.TaskCenterEvent{
|
||||
ID: eventRowID,
|
||||
SysOrigin: sysOrigin,
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
UserID: userID,
|
||||
TaskID: task.ID,
|
||||
TaskCode: task.TaskCode,
|
||||
TaskCategory: task.TaskCategory,
|
||||
CycleKey: cycleKey,
|
||||
DeltaValue: req.DeltaValue,
|
||||
CompletedValue: progress.CompletedValue,
|
||||
RawJSON: rawJSON,
|
||||
OccurredAt: occurredAt,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&eventRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return nil
|
||||
if updated == 0 {
|
||||
resultReason = "no_updated_task"
|
||||
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusIgnored, resultReason)
|
||||
}
|
||||
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusSuccess, "")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if updated == 0 {
|
||||
reason = "no_updated_task"
|
||||
if duplicate {
|
||||
return &EventResponse{Processed: false, Updated: 0, Reason: "duplicate_event"}, nil
|
||||
}
|
||||
return &EventResponse{Processed: updated > 0, Updated: updated, Reason: reason}, nil
|
||||
return &EventResponse{Processed: updated > 0, Updated: updated, Reason: resultReason}, nil
|
||||
}
|
||||
|
||||
func insertTaskEventDedupTx(tx *gorm.DB, sysOrigin, eventID, eventType string, userID int64) (bool, error) {
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
now := time.Now()
|
||||
row := model.TaskCenterEventDedup{
|
||||
ID: id,
|
||||
SysOrigin: sysOrigin,
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
UserID: userID,
|
||||
Status: DedupStatusProcessing,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "event_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&row)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func updateTaskEventDedupTx(tx *gorm.DB, sysOrigin, eventID, status, failureReason string) error {
|
||||
return tx.Model(&model.TaskCenterEventDedup{}).
|
||||
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"failure_reason": truncateTaskCenterFailure(failureReason),
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func createTaskEventArchiveOutboxTx(tx *gorm.DB, sysOrigin, eventID, eventType string, userID int64, occurredAt time.Time, rawJSON string) error {
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
row := model.TaskCenterEventArchiveOutbox{
|
||||
ID: id,
|
||||
SysOrigin: sysOrigin,
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
UserID: userID,
|
||||
OccurredAt: occurredAt,
|
||||
RawJSON: rawJSON,
|
||||
Status: ArchiveStatusPending,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "event_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
func buildArchiveRawJSON(req EventRequest, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt, receivedAt time.Time, processResult, processReason string) string {
|
||||
row := map[string]any{
|
||||
"schema_version": 1,
|
||||
"sys_origin": sysOrigin,
|
||||
"event_id": eventID,
|
||||
"event_type": eventType,
|
||||
"user_id": userID,
|
||||
"delta_value": deltaValue,
|
||||
"occurred_at": occurredAt.UTC().Format(time.RFC3339Nano),
|
||||
"received_at": receivedAt.UTC().Format(time.RFC3339Nano),
|
||||
"payload": req.Payload,
|
||||
}
|
||||
if processResult != "" {
|
||||
row["process_result"] = processResult
|
||||
}
|
||||
if processReason != "" {
|
||||
row["process_reason"] = processReason
|
||||
}
|
||||
bytes, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func truncateTaskCenterFailure(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 512 {
|
||||
return value
|
||||
}
|
||||
return value[:512]
|
||||
}
|
||||
|
||||
func (s *Service) loadOrCreateProgressTx(tx *gorm.DB, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, cycleKey string) (model.TaskCenterUserProgress, error) {
|
||||
|
||||
202
internal/service/taskcenter/lifecycle.go
Normal file
202
internal/service/taskcenter/lifecycle.go
Normal file
@ -0,0 +1,202 @@
|
||||
package taskcenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"github.com/apache/rocketmq-clients/golang/v5/credentials"
|
||||
)
|
||||
|
||||
// StartMessageConsumer 启动任务中心事件 MQ 消费链路。
|
||||
func (s *Service) StartMessageConsumer(ctx context.Context) error {
|
||||
if !s.cfg.TaskCenter.MQ.Enabled {
|
||||
log.Printf("task center rocketmq consumer disabled")
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.TaskCenter.MQ.Endpoint) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.MQ.AccessKey) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.MQ.AccessSecret) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.MQ.Topic) == "" ||
|
||||
strings.TrimSpace(s.cfg.TaskCenter.MQ.ConsumerGroup) == "" {
|
||||
log.Printf("task center rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
|
||||
return nil
|
||||
}
|
||||
|
||||
go s.startTaskCenterRocketMQConsumerWithRetry(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) startTaskCenterRocketMQConsumerWithRetry(ctx context.Context) {
|
||||
retryDelay := 5 * time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.startTaskCenterRocketMQConsumer(ctx); err != nil {
|
||||
log.Printf("start task center rocketmq consumer failed: %v; retry in %s", err, retryDelay)
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < time.Minute {
|
||||
retryDelay *= 2
|
||||
if retryDelay > time.Minute {
|
||||
retryDelay = time.Minute
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startTaskCenterRocketMQConsumer(ctx context.Context) error {
|
||||
filter := rmq.SUB_ALL
|
||||
if tag := strings.TrimSpace(s.cfg.TaskCenter.MQ.Tag); tag != "" && tag != "*" {
|
||||
filter = rmq.NewFilterExpression(tag)
|
||||
}
|
||||
|
||||
consumer, err := rmq.NewPushConsumer(&rmq.Config{
|
||||
Endpoint: s.cfg.TaskCenter.MQ.Endpoint,
|
||||
NameSpace: s.cfg.TaskCenter.MQ.Namespace,
|
||||
ConsumerGroup: s.cfg.TaskCenter.MQ.ConsumerGroup,
|
||||
Credentials: &credentials.SessionCredentials{
|
||||
AccessKey: s.cfg.TaskCenter.MQ.AccessKey,
|
||||
AccessSecret: s.cfg.TaskCenter.MQ.AccessSecret,
|
||||
SecurityToken: s.cfg.TaskCenter.MQ.SecurityToken,
|
||||
},
|
||||
},
|
||||
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
|
||||
s.cfg.TaskCenter.MQ.Topic: filter,
|
||||
}),
|
||||
rmq.WithPushConsumptionThreadCount(8),
|
||||
rmq.WithPushMaxCacheMessageCount(512),
|
||||
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
|
||||
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
|
||||
if messageView == nil {
|
||||
return rmq.SUCCESS
|
||||
}
|
||||
if err := s.processTaskCenterMQMessage(context.Background(), messageView); err != nil {
|
||||
log.Printf("task center mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return rmq.FAILURE
|
||||
}
|
||||
return rmq.SUCCESS
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consumer.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.rocketConsumer = consumer
|
||||
log.Printf("task center rocketmq consumer started. topic=%s group=%s tag=%s",
|
||||
s.cfg.TaskCenter.MQ.Topic,
|
||||
s.cfg.TaskCenter.MQ.ConsumerGroup,
|
||||
s.cfg.TaskCenter.MQ.Tag,
|
||||
)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if s.rocketConsumer != nil {
|
||||
_ = s.rocketConsumer.GracefulStop()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) processTaskCenterMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
|
||||
payload := strings.TrimSpace(string(messageView.GetBody()))
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
req, err := decodeTaskCenterEventPayload(payload, s.cfg.TaskCenter.MQ.Tag)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
|
||||
log.Printf("drop malformed task center message. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.EventID) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.ProcessEvent(ctx, req); err != nil {
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
|
||||
log.Printf("drop invalid task center message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTaskCenterEventPayload(payload string, expectedTag string) (EventRequest, error) {
|
||||
var req EventRequest
|
||||
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||
return req, err
|
||||
}
|
||||
if strings.TrimSpace(req.EventID) != "" || strings.TrimSpace(req.EventType) != "" || req.UserID.Int64() > 0 {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var envelope taskCenterMessageEventEnvelope
|
||||
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
|
||||
return req, err
|
||||
}
|
||||
if shouldSkipTaskCenterEnvelope(envelope.Tag, expectedTag) {
|
||||
return EventRequest{}, nil
|
||||
}
|
||||
bodyPayload, ok, err := envelope.bodyPayload()
|
||||
if err != nil || !ok {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(bodyPayload), &req); err != nil {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type taskCenterMessageEventEnvelope struct {
|
||||
Tag string `json:"tag"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func (e taskCenterMessageEventEnvelope) bodyPayload() (string, bool, error) {
|
||||
raw := strings.TrimSpace(string(e.Body))
|
||||
if raw == "" || raw == "null" {
|
||||
return "", false, nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "\"") {
|
||||
var body string
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
return body, body != "", nil
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
func shouldSkipTaskCenterEnvelope(actualTag, expectedTag string) bool {
|
||||
actualTag = strings.TrimSpace(actualTag)
|
||||
expectedTag = strings.TrimSpace(expectedTag)
|
||||
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
|
||||
return false
|
||||
}
|
||||
return actualTag != expectedTag
|
||||
}
|
||||
@ -2,7 +2,9 @@ package taskcenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/config"
|
||||
@ -25,6 +27,8 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
|
||||
&model.TaskCenterConditionJumpConfig{},
|
||||
&model.TaskCenterUserProgress{},
|
||||
&model.TaskCenterEvent{},
|
||||
&model.TaskCenterEventDedup{},
|
||||
&model.TaskCenterEventArchiveOutbox{},
|
||||
&model.TaskCenterClaimRecord{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
@ -40,7 +44,7 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
|
||||
|
||||
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
||||
java := &taskCenterTestGateway{events: map[string]bool{}}
|
||||
service, _ := newTestService(t, java)
|
||||
service, db := newTestService(t, java)
|
||||
ctx := context.Background()
|
||||
|
||||
configResp, err := service.SaveConfig(ctx, SaveConfigRequest{
|
||||
@ -92,6 +96,46 @@ func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
||||
if !eventResp.Processed || eventResp.Updated != 1 {
|
||||
t.Fatalf("ReceiveEvent() resp = %+v", eventResp)
|
||||
}
|
||||
duplicateResp, err := service.ReceiveEvent(ctx, EventRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
EventID: "event-1",
|
||||
EventType: "MIC_DURATION_SECONDS",
|
||||
UserID: flexibleInt64(user.UserID),
|
||||
DeltaValue: 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveEvent() duplicate error = %v", err)
|
||||
}
|
||||
if duplicateResp.Processed || duplicateResp.Reason != "duplicate_event" {
|
||||
t.Fatalf("ReceiveEvent() duplicate resp = %+v", duplicateResp)
|
||||
}
|
||||
var dedupCount int64
|
||||
if err := db.Model(&model.TaskCenterEventDedup{}).
|
||||
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
|
||||
Count(&dedupCount).Error; err != nil {
|
||||
t.Fatalf("count dedup: %v", err)
|
||||
}
|
||||
if dedupCount != 1 {
|
||||
t.Fatalf("dedup rows = %d, want 1", dedupCount)
|
||||
}
|
||||
var outboxCount int64
|
||||
if err := db.Model(&model.TaskCenterEventArchiveOutbox{}).
|
||||
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
|
||||
Count(&outboxCount).Error; err != nil {
|
||||
t.Fatalf("count archive outbox: %v", err)
|
||||
}
|
||||
if outboxCount != 1 {
|
||||
t.Fatalf("archive outbox rows = %d, want 1", outboxCount)
|
||||
}
|
||||
var legacyEventCount int64
|
||||
if err := db.Model(&model.TaskCenterEvent{}).
|
||||
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
|
||||
Count(&legacyEventCount).Error; err != nil {
|
||||
t.Fatalf("count legacy task event: %v", err)
|
||||
}
|
||||
if legacyEventCount != 0 {
|
||||
t.Fatalf("legacy task event rows = %d, want 0", legacyEventCount)
|
||||
}
|
||||
|
||||
tasks, err = service.ListTasks(ctx, user)
|
||||
if err != nil {
|
||||
@ -196,6 +240,104 @@ func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventWithProductionGameEventShape(t *testing.T) {
|
||||
service, db := newTestService(t, nil)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
configRow := model.TaskCenterConfig{
|
||||
ID: 2052721146352111616,
|
||||
SysOrigin: "LIKEI",
|
||||
Enabled: true,
|
||||
Timezone: "Asia/Riyadh",
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if err := db.Create(&configRow).Error; err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
tasks := []model.TaskCenterTaskConfig{
|
||||
{
|
||||
ID: 2053002284639457280,
|
||||
ConfigID: configRow.ID,
|
||||
SysOrigin: "LIKEI",
|
||||
TaskCode: "GAME_200K",
|
||||
TaskCategory: TaskCategoryDaily,
|
||||
Enabled: true,
|
||||
TaskName: "Spend 200K coins in the game.",
|
||||
TaskDesc: "progress",
|
||||
ConditionType: EventTypeGameConsumeGold,
|
||||
TargetValue: 200000,
|
||||
TargetUnit: "gold",
|
||||
RewardGold: 500,
|
||||
JumpType: "ROOM_RANDOM_WITH_MIC",
|
||||
JumpPage: "/room?gameId=0",
|
||||
SortOrder: 30,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
},
|
||||
{
|
||||
ID: 2053005491679793152,
|
||||
ConfigID: configRow.ID,
|
||||
SysOrigin: "LIKEI",
|
||||
TaskCode: "GAME_40K",
|
||||
TaskCategory: TaskCategoryNewbie,
|
||||
Enabled: true,
|
||||
TaskName: "Spend 40K coins in the game.",
|
||||
TaskDesc: "progress",
|
||||
ConditionType: EventTypeGameConsumeGold,
|
||||
TargetValue: 40000,
|
||||
TargetUnit: "gold",
|
||||
RewardGold: 200,
|
||||
JumpType: "ROOM_RANDOM_WITH_MIC",
|
||||
SortOrder: 30,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&tasks).Error; err != nil {
|
||||
t.Fatalf("create tasks: %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.ProcessEvent(ctx, EventRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
EventID: "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930",
|
||||
EventType: EventTypeGameConsumeGold,
|
||||
UserID: flexibleInt64(2053169065266049025),
|
||||
DeltaValue: 100,
|
||||
OccurredAt: "2026-05-09T20:48:30.291+03:00",
|
||||
Payload: map[string]any{
|
||||
"diffMsg": "bet",
|
||||
"gameId": float64(1067),
|
||||
"gameRoundId": "20260510000171",
|
||||
"orderId": "ee2d4218-4f58-406a-bf0f-d6b09f7e9930",
|
||||
"provider": "BAISHUN",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvent() error = %v", err)
|
||||
}
|
||||
if !resp.Processed || resp.Updated != 2 {
|
||||
t.Fatalf("ProcessEvent() resp = %+v, want processed with two task updates", resp)
|
||||
}
|
||||
var progressCount int64
|
||||
if err := db.Model(&model.TaskCenterUserProgress{}).
|
||||
Where("sys_origin = ? AND user_id = ? AND completed_value = ?", "LIKEI", int64(2053169065266049025), int64(100)).
|
||||
Count(&progressCount).Error; err != nil {
|
||||
t.Fatalf("count progress: %v", err)
|
||||
}
|
||||
if progressCount != 2 {
|
||||
t.Fatalf("progress rows = %d, want 2", progressCount)
|
||||
}
|
||||
var outbox model.TaskCenterEventArchiveOutbox
|
||||
if err := db.Where("sys_origin = ? AND event_id = ?", "LIKEI", "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930").
|
||||
First(&outbox).Error; err != nil {
|
||||
t.Fatalf("load archive outbox: %v", err)
|
||||
}
|
||||
if outbox.Status != ArchiveStatusPending || !strings.Contains(outbox.RawJSON, "BAISHUN_GAME_CONSUME") {
|
||||
t.Fatalf("archive outbox = %+v", outbox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionJumpConfigAppliesToTasks(t *testing.T) {
|
||||
service, _ := newTestService(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -32,6 +33,15 @@ const (
|
||||
ClaimStatusSuccess = "SUCCESS"
|
||||
ClaimStatusFailed = "FAILED"
|
||||
|
||||
DedupStatusProcessing = "PROCESSING"
|
||||
DedupStatusSuccess = "SUCCESS"
|
||||
DedupStatusIgnored = "IGNORED"
|
||||
|
||||
ArchiveStatusPending = "PENDING"
|
||||
ArchiveStatusProcessing = "PROCESSING"
|
||||
ArchiveStatusUploaded = "UPLOADED"
|
||||
ArchiveStatusFailed = "FAILED"
|
||||
|
||||
JumpTypeRechargeInApp = "RECHARGE_IN_APP"
|
||||
|
||||
defaultTaskCenterTimezone = "Asia/Riyadh"
|
||||
@ -54,10 +64,11 @@ type taskCenterJavaGateway interface {
|
||||
|
||||
// Service 负责任务配置、进度、领取和事件入库。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db taskCenterDB
|
||||
java taskCenterJavaGateway
|
||||
location *time.Location
|
||||
cfg config.Config
|
||||
db taskCenterDB
|
||||
java taskCenterJavaGateway
|
||||
location *time.Location
|
||||
rocketConsumer rmq.PushConsumer
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
||||
@ -223,23 +234,6 @@ type ClaimRecordView struct {
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
type EventView struct {
|
||||
ID int64 `json:"id,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
EventID string `json:"eventId"`
|
||||
EventType string `json:"eventType"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
TaskID int64 `json:"taskId,string"`
|
||||
TaskCode string `json:"taskCode"`
|
||||
TaskCategory string `json:"taskCategory"`
|
||||
CycleKey string `json:"cycleKey"`
|
||||
DeltaValue int64 `json:"deltaValue"`
|
||||
CompletedValue int64 `json:"completedValue"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
CreateTime string `json:"createTime"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
type flexibleInt64 int64
|
||||
|
||||
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@ -108,6 +108,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI
|
||||
return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing")
|
||||
}
|
||||
|
||||
if s != nil && s.regionResolver != nil {
|
||||
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
if err == nil {
|
||||
if normalized := strings.ToUpper(strings.TrimSpace(regionCode)); normalized != "" {
|
||||
return normalized, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mapping model.VoiceRoomRegionCountry
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).
|
||||
|
||||
@ -176,6 +176,38 @@ func TestDelayedPacketSendsRegionIMBroadcastOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserRegionPrefersJavaRegionResolver(t *testing.T) {
|
||||
service, _, _ := newVoiceRoomRedPacketTestService(t)
|
||||
service.SetRegionResolver(fakeRegionResolver{
|
||||
regions: map[string]string{"LIKEI|SA": "中东区"},
|
||||
})
|
||||
fakeRegionIM := &fakeRegionIMGateway{}
|
||||
service.regionIM = fakeRegionIM
|
||||
ctx := context.Background()
|
||||
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
|
||||
presence, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001})
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeat() error = %v", err)
|
||||
}
|
||||
if presence.RegionCode != "中东区" {
|
||||
t.Fatalf("presence region = %q, want Java region", presence.RegionCode)
|
||||
}
|
||||
|
||||
_, err = service.Send(ctx, sender, SendRequest{
|
||||
RequestID: "req-delayed-java-region",
|
||||
RoomID: 9001,
|
||||
PacketMode: packetModeDelayed,
|
||||
TotalAmount: 15000,
|
||||
TotalCount: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
if len(fakeRegionIM.sent) != 1 || fakeRegionIM.sent[0].regionCode != "中东区" {
|
||||
t.Fatalf("sent region messages = %+v, want one Java-region message", fakeRegionIM.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImmediatePacketSendsRoomIMMessage(t *testing.T) {
|
||||
service, _, _ := newVoiceRoomRedPacketTestService(t)
|
||||
fakeIM := &fakeIMGateway{}
|
||||
@ -410,6 +442,19 @@ func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrig
|
||||
return "@region-" + strings.ToLower(regionCode), nil
|
||||
}
|
||||
|
||||
type fakeRegionResolver struct {
|
||||
regions map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
if g.err != nil {
|
||||
return "", g.err
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
return strings.TrimSpace(g.regions[key]), nil
|
||||
}
|
||||
|
||||
type fakeWalletGateway struct {
|
||||
balance map[int64]int64
|
||||
events map[string]struct{}
|
||||
|
||||
@ -96,16 +96,21 @@ type roomProfileGateway interface {
|
||||
MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error)
|
||||
}
|
||||
|
||||
type regionResolver interface {
|
||||
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
|
||||
}
|
||||
|
||||
// Service 承接语音房红包配置、发送、领取、在线态和退款补偿。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db voiceRoomRedPacketDB
|
||||
redis redis.Cmdable
|
||||
wallet walletGateway
|
||||
im imGateway
|
||||
regionIM regionIMGateway
|
||||
roomProfiles roomProfileGateway
|
||||
notifier *redisNotifier
|
||||
cfg config.Config
|
||||
db voiceRoomRedPacketDB
|
||||
redis redis.Cmdable
|
||||
wallet walletGateway
|
||||
im imGateway
|
||||
regionIM regionIMGateway
|
||||
roomProfiles roomProfileGateway
|
||||
regionResolver regionResolver
|
||||
notifier *redisNotifier
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, wallet walletGateway, regionIM regionIMGateway, roomProfiles ...roomProfileGateway) *Service {
|
||||
@ -124,6 +129,12 @@ func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable,
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) SetRegionResolver(resolver regionResolver) {
|
||||
if s != nil {
|
||||
s.regionResolver = resolver
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
ID int64 `json:"id,string"`
|
||||
|
||||
29
migrations/030_gm_vip_gift_record.sql
Normal file
29
migrations/030_gm_vip_gift_record.sql
Normal file
@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS `gm_vip_gift_record` (
|
||||
`id` bigint NOT NULL COMMENT '记录ID',
|
||||
`user_id` bigint NOT NULL COMMENT 'APP用户ID',
|
||||
`sys_origin` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统来源',
|
||||
`from_level` int NOT NULL DEFAULT '0' COMMENT '赠送前VIP等级',
|
||||
`vip_level` int NOT NULL COMMENT '赠送VIP等级',
|
||||
`level_code` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '等级编码',
|
||||
`display_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '显示名称',
|
||||
`duration_days` int NOT NULL DEFAULT '30' COMMENT '赠送天数',
|
||||
`price_gold` bigint NOT NULL DEFAULT '0' COMMENT '对应购买价格,金币',
|
||||
`start_at` datetime(3) DEFAULT NULL COMMENT '权益开始时间',
|
||||
`expire_at` datetime(3) DEFAULT NULL COMMENT '权益到期时间',
|
||||
`order_id` bigint NOT NULL COMMENT '关联VIP订单ID',
|
||||
`event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '赠送幂等事件ID',
|
||||
`status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'SUCCESS' COMMENT '状态',
|
||||
`applicant_id` bigint NOT NULL COMMENT '操作人ID',
|
||||
`applicant_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称',
|
||||
`remarks` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`executed_time` datetime(3) DEFAULT NULL COMMENT '执行时间',
|
||||
`create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
`update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
`create_user` bigint DEFAULT NULL COMMENT '创建用户',
|
||||
`update_user` bigint DEFAULT NULL COMMENT '更新用户',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_gm_vip_gift_event` (`event_id`),
|
||||
KEY `idx_gm_vip_gift_user` (`user_id`),
|
||||
KEY `idx_gm_vip_gift_applicant` (`applicant_id`),
|
||||
KEY `idx_gm_vip_gift_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GM VIP赠送记录';
|
||||
35
migrations/030_task_center_event_dedup_archive.sql
Normal file
35
migrations/030_task_center_event_dedup_archive.sql
Normal file
@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS `task_center_event_dedup` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||
`event_id` varchar(128) NOT NULL COMMENT '业务事件ID',
|
||||
`event_type` varchar(64) NOT NULL COMMENT '事件类型',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`status` varchar(32) NOT NULL COMMENT '处理状态',
|
||||
`failure_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_task_center_event_dedup` (`sys_origin`, `event_id`),
|
||||
KEY `idx_task_center_event_dedup_status` (`status`),
|
||||
KEY `idx_task_center_event_dedup_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心事件幂等表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `task_center_event_archive_outbox` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||
`event_id` varchar(128) NOT NULL COMMENT '业务事件ID',
|
||||
`event_type` varchar(64) NOT NULL COMMENT '事件类型',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`occurred_at` datetime(3) DEFAULT NULL COMMENT '业务发生时间',
|
||||
`raw_json` text NOT NULL COMMENT '待归档原始事件JSON',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '归档状态',
|
||||
`retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数',
|
||||
`cos_key` varchar(512) NOT NULL DEFAULT '' COMMENT 'COS对象Key',
|
||||
`failure_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
`archived_at` datetime(3) DEFAULT NULL COMMENT '归档完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_task_center_archive_event` (`sys_origin`, `event_id`),
|
||||
KEY `idx_task_center_archive_status_time` (`status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务中心事件COS归档Outbox';
|
||||
52
migrations/031_binance_recharge.sql
Normal file
52
migrations/031_binance_recharge.sql
Normal file
@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS `binance_recharge_config` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||
`api_key` varchar(255) NOT NULL DEFAULT '' COMMENT 'Binance API Key',
|
||||
`api_secret` varchar(255) NOT NULL DEFAULT '' COMMENT 'Binance API Secret',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用自动充值验证',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_binance_recharge_config_origin` (`sys_origin`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `binance_recharge_rate` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`config_id` bigint NOT NULL COMMENT '配置ID',
|
||||
`min_amount` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '最小USDT金额,包含',
|
||||
`max_amount` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '最大USDT金额,不包含;0表示不限制',
|
||||
`gold_per_usd` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '1 USDT 对应金币',
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_binance_recharge_rate_config` (`config_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值金币区间';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `binance_recharge_verify_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||
`dealer_user_id` bigint NOT NULL COMMENT '提交验证的币商用户ID',
|
||||
`target_user_id` bigint NOT NULL COMMENT '发放用户ID',
|
||||
`order_no` varchar(128) NOT NULL COMMENT '业务订单号',
|
||||
`transfer_type` varchar(32) NOT NULL COMMENT 'BINANCE_PAY/CHAIN',
|
||||
`bill_no` varchar(160) NOT NULL COMMENT '币安账单号/链上txId',
|
||||
`amount_usdt` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '验证USDT金额',
|
||||
`gold_amount` bigint NOT NULL DEFAULT 0 COMMENT '发放金币',
|
||||
`rate_gold_per_usd` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '命中金币比例',
|
||||
`binance_transaction_id` varchar(180) NOT NULL DEFAULT '' COMMENT '币安交易ID',
|
||||
`binance_order_type` varchar(64) NOT NULL DEFAULT '' COMMENT '币安交易类型',
|
||||
`binance_raw_json` text COMMENT '币安原始记录',
|
||||
`status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED',
|
||||
`fail_reason` varchar(500) NOT NULL DEFAULT '',
|
||||
`remark` varchar(500) NOT NULL DEFAULT '',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_binance_recharge_verify_order` (`sys_origin`, `order_no`),
|
||||
UNIQUE KEY `uk_binance_recharge_verify_bill` (`sys_origin`, `transfer_type`, `bill_no`),
|
||||
KEY `idx_binance_recharge_verify_origin` (`sys_origin`),
|
||||
KEY `idx_binance_recharge_verify_dealer` (`dealer_user_id`),
|
||||
KEY `idx_binance_recharge_verify_target` (`target_user_id`),
|
||||
KEY `idx_binance_recharge_verify_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值验证记录';
|
||||
Loading…
x
Reference in New Issue
Block a user