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/router"
|
||||||
"chatapp3-golang/internal/service/apppopup"
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
|
"chatapp3-golang/internal/service/managercenter"
|
||||||
"chatapp3-golang/internal/service/rechargeagency"
|
"chatapp3-golang/internal/service/rechargeagency"
|
||||||
"chatapp3-golang/internal/service/rechargereward"
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
"chatapp3-golang/internal/service/regionimgroup"
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
@ -44,6 +46,7 @@ func main() {
|
|||||||
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
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)
|
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)
|
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
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)
|
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
vipService := vip.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 := 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)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
|
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
|
||||||
|
|
||||||
baishunService.SetTaskEventReporter(taskCenterService)
|
baishunService.SetTaskEventReporter(taskCenterService)
|
||||||
gameOpenService.SetTaskEventReporter(taskCenterService)
|
gameOpenService.SetTaskEventReporter(taskCenterService)
|
||||||
@ -71,6 +76,12 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
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)
|
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||||
|
|
||||||
gameProviders := gameprovider.NewRegistry(
|
gameProviders := gameprovider.NewRegistry(
|
||||||
@ -81,10 +92,12 @@ func main() {
|
|||||||
AppPopup: appPopupService,
|
AppPopup: appPopupService,
|
||||||
Invite: inviteService,
|
Invite: inviteService,
|
||||||
Baishun: baishunService,
|
Baishun: baishunService,
|
||||||
|
BinanceRecharge: binanceRechargeService,
|
||||||
Lingxian: lingxianService,
|
Lingxian: lingxianService,
|
||||||
GameOpen: gameOpenService,
|
GameOpen: gameOpenService,
|
||||||
GameProviders: gameProviders,
|
GameProviders: gameProviders,
|
||||||
LuckyGift: luckyGiftService,
|
LuckyGift: luckyGiftService,
|
||||||
|
ManagerCenter: managerCenterService,
|
||||||
HostCenter: hostCenterService,
|
HostCenter: hostCenterService,
|
||||||
RechargeAgency: rechargeAgencyService,
|
RechargeAgency: rechargeAgencyService,
|
||||||
RechargeReward: rechargeRewardService,
|
RechargeReward: rechargeRewardService,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
"chatapp3-golang/internal/service/regionimgroup"
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"context"
|
"context"
|
||||||
@ -24,8 +25,10 @@ func main() {
|
|||||||
|
|
||||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
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)
|
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 := 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)
|
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
@ -36,6 +39,12 @@ func main() {
|
|||||||
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start week star message consumer failed: %v", err)
|
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)
|
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
|
||||||
|
|
||||||
log.Printf("chatapp3-golang consumer started")
|
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/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
|
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/dchest/siphash v1.2.3 // 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/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/golang/mock v1.6.0 // indirect
|
github.com/golang/mock v1.6.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/natefinch/lumberjack v2.0.0+incompatible // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/valyala/fastrand v1.1.0 // 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/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
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/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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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/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-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
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-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/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/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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
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.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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type Config struct {
|
|||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
GameOpen GameOpenConfig
|
GameOpen GameOpenConfig
|
||||||
LuckyGift LuckyGiftConfig
|
LuckyGift LuckyGiftConfig
|
||||||
|
BinanceRecharge BinanceRechargeConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPConfig 保存 HTTP 服务运行配置。
|
// HTTPConfig 保存 HTTP 服务运行配置。
|
||||||
@ -88,6 +89,34 @@ type TaskCenterConfig struct {
|
|||||||
DefaultSysOrigin string
|
DefaultSysOrigin string
|
||||||
Timezone string
|
Timezone string
|
||||||
InternalEventTTL time.Duration
|
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 保存周榜模块配置。
|
// WeekStarConfig 保存周榜模块配置。
|
||||||
@ -176,6 +205,11 @@ type LuckyGiftConfig struct {
|
|||||||
Providers []LuckyGiftProviderConfig
|
Providers []LuckyGiftProviderConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
|
||||||
|
type BinanceRechargeConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftProviderConfig struct {
|
type LuckyGiftProviderConfig struct {
|
||||||
StandardID int64 `json:"standardId"`
|
StandardID int64 `json:"standardId"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@ -193,6 +227,9 @@ func Load() Config {
|
|||||||
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
|
||||||
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
|
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{
|
return Config{
|
||||||
HTTP: HTTPConfig{
|
HTTP: HTTPConfig{
|
||||||
@ -215,7 +252,7 @@ func Load() Config {
|
|||||||
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
|
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"),
|
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),
|
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"),
|
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
|
||||||
},
|
},
|
||||||
Worker: WorkerConfig{
|
Worker: WorkerConfig{
|
||||||
@ -255,6 +292,28 @@ func Load() Config {
|
|||||||
[]string{"CHATAPP_TASK_CENTER_EVENT_TTL_HOURS", "TASK_CENTER_EVENT_TTL_HOURS"},
|
[]string{"CHATAPP_TASK_CENTER_EVENT_TTL_HOURS", "TASK_CENTER_EVENT_TTL_HOURS"},
|
||||||
72,
|
72,
|
||||||
)) * time.Hour,
|
)) * 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{
|
WeekStar: WeekStarConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
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"),
|
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
||||||
Providers: loadLuckyGiftConfigs(),
|
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) {
|
func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) {
|
||||||
t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key")
|
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)
|
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 透传到道具使用切换接口。
|
// SwitchUseProps 透传到道具使用切换接口。
|
||||||
func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
|
func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
|
||||||
return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID)
|
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)
|
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 透传到资料网关。
|
// GetUserProfileByAccount 透传到资料网关。
|
||||||
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||||
return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account)
|
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)
|
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 透传到房间流水网关。
|
// ListCurrentWeekRoomContribution 透传到房间流水网关。
|
||||||
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
|
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
|
||||||
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
|
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)
|
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 负责认证相关接口。
|
// AuthGateway 负责认证相关接口。
|
||||||
type AuthGateway struct {
|
type AuthGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -256,6 +301,10 @@ func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, us
|
|||||||
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
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 负责活动奖励发放接口。
|
// RewardDispatchGateway 负责活动奖励发放接口。
|
||||||
type RewardDispatchGateway struct {
|
type RewardDispatchGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -301,6 +350,16 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
|
|||||||
return g.client.GetRewardGroupDetail(ctx, groupID)
|
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 负责用户资料相关接口。
|
// ProfileGateway 负责用户资料相关接口。
|
||||||
type ProfileGateway struct {
|
type ProfileGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -316,6 +375,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author
|
|||||||
return g.client.GetUserRegion(ctx, userID, authorization)
|
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 根据账号查询用户资料。
|
// GetUserProfileByAccount 根据账号查询用户资料。
|
||||||
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||||
return g.client.GetUserProfileByAccount(ctx, sysOrigin, account)
|
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)
|
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 负责房间资料和房间流水相关接口。
|
// RoomGateway 负责房间资料和房间流水相关接口。
|
||||||
type RoomGateway struct {
|
type RoomGateway struct {
|
||||||
client *Client
|
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) {
|
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)
|
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"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chatapp3-golang/internal/config"
|
"chatapp3-golang/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
regionConfigCacheMu sync.Mutex
|
||||||
|
regionConfigCacheMap map[string]regionConfigCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCredential struct {
|
type UserCredential struct {
|
||||||
@ -39,15 +42,16 @@ type ConsoleAccount struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserProfile struct {
|
type UserProfile struct {
|
||||||
ID Int64Value `json:"id"`
|
ID Int64Value `json:"id"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
UserAvatar string `json:"userAvatar"`
|
UserAvatar string `json:"userAvatar"`
|
||||||
UserNickname string `json:"userNickname"`
|
UserNickname string `json:"userNickname"`
|
||||||
CountryID Int64Value `json:"countryId"`
|
CountryID Int64Value `json:"countryId"`
|
||||||
CountryCode string `json:"countryCode"`
|
CountryCode string `json:"countryCode"`
|
||||||
CountryName string `json:"countryName"`
|
CountryName string `json:"countryName"`
|
||||||
RegionCode string `json:"regionCode"`
|
RegionCode string `json:"regionCode"`
|
||||||
OriginSys string `json:"originSys"`
|
OriginSys string `json:"originSys"`
|
||||||
|
AccountStatus string `json:"accountStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegion struct {
|
type UserRegion struct {
|
||||||
@ -55,6 +59,21 @@ type UserRegion struct {
|
|||||||
RegionCode string `json:"regionCode"`
|
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 {
|
type RoomContributionActivityCount struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RoomID Int64Value `json:"roomId"`
|
RoomID Int64Value `json:"roomId"`
|
||||||
@ -141,6 +160,31 @@ type GivePropsBackpackRequest struct {
|
|||||||
OpUser int64 `json:"opUser,omitempty"`
|
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 {
|
type SendActivityRewardRequest struct {
|
||||||
TrackID int64 `json:"trackId"`
|
TrackID int64 `json:"trackId"`
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
@ -258,6 +302,25 @@ type FreightSellerProfile struct {
|
|||||||
OriginSys string `json:"originSys"`
|
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 {
|
type freightSellerPageResponse struct {
|
||||||
Status *bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
ErrorMsg string `json:"errorMsg"`
|
ErrorMsg string `json:"errorMsg"`
|
||||||
@ -370,6 +433,7 @@ func New(cfg config.Config) *Client {
|
|||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: cfg.HTTP.Timeout,
|
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)
|
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 {
|
func (c *Client) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
|
||||||
endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" +
|
endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" +
|
||||||
url.QueryEscape(strconv.FormatInt(userID, 10)) +
|
url.QueryEscape(strconv.FormatInt(userID, 10)) +
|
||||||
@ -687,6 +771,94 @@ func (c *Client) PageFreightSellers(ctx context.Context, authorization string, s
|
|||||||
return resp.FreightSellerPage, nil
|
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) {
|
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))
|
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||||
var resp resultResponse[UserProfile]
|
var resp resultResponse[UserProfile]
|
||||||
@ -715,6 +887,75 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
|
|||||||
return resp.Body, nil
|
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) {
|
func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
|
||||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" +
|
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" +
|
||||||
url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account))
|
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)
|
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) {
|
func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||||
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
|
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
|
||||||
var resp resultResponse[map[string]int64]
|
var resp resultResponse[map[string]int64]
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
|
func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
|
||||||
@ -29,3 +35,45 @@ func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
|
|||||||
t.Fatalf("Amount = %q, want 1.60", item.Amount)
|
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" }
|
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 保存任务奖励领取记录。
|
// TaskCenterClaimRecord 保存任务奖励领取记录。
|
||||||
type TaskCenterClaimRecord struct {
|
type TaskCenterClaimRecord struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
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/repo"
|
||||||
"chatapp3-golang/internal/service/apppopup"
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
|
"chatapp3-golang/internal/service/managercenter"
|
||||||
"chatapp3-golang/internal/service/rechargeagency"
|
"chatapp3-golang/internal/service/rechargeagency"
|
||||||
"chatapp3-golang/internal/service/rechargereward"
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
"chatapp3-golang/internal/service/regionimgroup"
|
"chatapp3-golang/internal/service/regionimgroup"
|
||||||
@ -33,10 +35,12 @@ type Services struct {
|
|||||||
AppPopup *apppopup.Service
|
AppPopup *apppopup.Service
|
||||||
Invite *invite.InviteService
|
Invite *invite.InviteService
|
||||||
Baishun *baishun.BaishunService
|
Baishun *baishun.BaishunService
|
||||||
|
BinanceRecharge *binancerecharge.Service
|
||||||
Lingxian *lingxian.Service
|
Lingxian *lingxian.Service
|
||||||
GameOpen *gameopen.GameOpenService
|
GameOpen *gameopen.GameOpenService
|
||||||
GameProviders *gameprovider.Registry
|
GameProviders *gameprovider.Registry
|
||||||
LuckyGift *luckygift.LuckyGiftService
|
LuckyGift *luckygift.LuckyGiftService
|
||||||
|
ManagerCenter *managercenter.Service
|
||||||
HostCenter *hostcenter.Service
|
HostCenter *hostcenter.Service
|
||||||
RechargeAgency *rechargeagency.Service
|
RechargeAgency *rechargeagency.Service
|
||||||
RechargeReward *rechargereward.Service
|
RechargeReward *rechargereward.Service
|
||||||
@ -65,6 +69,7 @@ func NewRouter(
|
|||||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||||
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
||||||
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||||
|
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
|
||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
@ -79,6 +84,7 @@ func NewRouter(
|
|||||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||||
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
||||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||||
|
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
||||||
|
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,21 +130,4 @@ func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient
|
|||||||
}
|
}
|
||||||
writeOK(c, resp)
|
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 {
|
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 {
|
func launchRoomID(cfg runtimeConfig, req gameprovider.LaunchRequest) string {
|
||||||
@ -307,6 +307,10 @@ func launchLang(req gameprovider.LaunchRequest) string {
|
|||||||
return defaultLaunchLang
|
return defaultLaunchLang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trimTokenPadding(token string) string {
|
||||||
|
return strings.TrimRight(strings.TrimSpace(token), "=")
|
||||||
|
}
|
||||||
|
|
||||||
func launchSign(cfg runtimeConfig, gameID, uid, token, roomID string) string {
|
func launchSign(cfg runtimeConfig, gameID, uid, token, roomID string) string {
|
||||||
sum := md5.Sum([]byte(gameID + uid + token + roomID + strings.TrimSpace(cfg.AppKey)))
|
sum := md5.Sum([]byte(gameID + uid + token + roomID + strings.TrimSpace(cfg.AppKey)))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@ -355,7 +355,7 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
|
|||||||
Active: true,
|
Active: true,
|
||||||
AppKey: "test-key",
|
AppKey: "test-key",
|
||||||
TestUID: "test-uid",
|
TestUID: "test-uid",
|
||||||
TestToken: "test-token",
|
TestToken: "test-token==",
|
||||||
TestRoomID: "test-room",
|
TestRoomID: "test-room",
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
UpdateTime: now,
|
UpdateTime: now,
|
||||||
@ -466,6 +466,9 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
|
|||||||
if got := query.Get("token"); got != "test-token" {
|
if got := query.Get("token"); got != "test-token" {
|
||||||
t.Fatalf("query token = %q, want test-token", got)
|
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 {
|
if got := query.Get("lang"); got != defaultLaunchLang {
|
||||||
t.Fatalf("query lang = %q, want %s", 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" {
|
if got := launch.LaunchConfig.(map[string]any)["roomid"]; got != "test-room" {
|
||||||
t.Fatalf("launchConfig.roomid = %v, want test-room", got)
|
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 {
|
if got := launch.LaunchConfig.(map[string]any)["lang"]; got != defaultLaunchLang {
|
||||||
t.Fatalf("launchConfig.lang = %v, want %s", 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")
|
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
|
var mapping model.VoiceRoomRegionCountry
|
||||||
err = s.db.WithContext(ctx).
|
err = s.db.WithContext(ctx).
|
||||||
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).
|
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) {
|
func newRegionIMGroupTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
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})
|
g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body})
|
||||||
return nil
|
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
|
SendCustomGroupMessage(ctx context.Context, groupID string, body any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service 负责区域通用 IM 群组创建、查询和区域广播。
|
type regionResolver interface {
|
||||||
type Service struct {
|
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
|
||||||
cfg config.Config
|
|
||||||
db dbHandle
|
|
||||||
im imGateway
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
return &Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
db: db,
|
db: db,
|
||||||
im: tencentim.NewClient(cfg.TencentIM),
|
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
|
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) {
|
func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) {
|
||||||
if len(inputs) == 0 {
|
if len(inputs) == 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "tasks is required")
|
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 {
|
func taskCenterSysOriginBySavedID(ctx context.Context, db taskCenterDB, configID int64, fallback string) string {
|
||||||
if configID <= 0 {
|
if configID <= 0 {
|
||||||
return fallback
|
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"
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportSimpleEvent 供 Go 内部业务模块上报任务进度事件。
|
// ReportSimpleEvent 供 Go 内部业务模块上报任务进度事件。
|
||||||
@ -23,7 +24,7 @@ func (s *Service) ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eve
|
|||||||
|
|
||||||
// ReportSimpleEventAt 和 ReportSimpleEvent 相同,但允许 MQ 等异步事件传入真实发生时间。
|
// 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 {
|
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,
|
SysOrigin: sysOrigin,
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
@ -40,6 +41,11 @@ func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, e
|
|||||||
|
|
||||||
// ReceiveEvent 接收后端业务事件并更新命中的任务进度。
|
// ReceiveEvent 接收后端业务事件并更新命中的任务进度。
|
||||||
func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventResponse, error) {
|
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)
|
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
|
||||||
eventID := strings.TrimSpace(req.EventID)
|
eventID := strings.TrimSpace(req.EventID)
|
||||||
if eventID == "" {
|
if eventID == "" {
|
||||||
@ -59,55 +65,50 @@ func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventRes
|
|||||||
if req.DeltaValue <= 0 {
|
if req.DeltaValue <= 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_delta_value", "deltaValue must be greater than 0")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_delta_value", "deltaValue must be greater than 0")
|
||||||
}
|
}
|
||||||
|
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
|
||||||
var existingCount int64
|
receivedAt := time.Now()
|
||||||
if err := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}).
|
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if bundle == nil {
|
|
||||||
return &EventResponse{Processed: false, Updated: 0, Reason: "task_center_disabled"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedTasks := make([]model.TaskCenterTaskConfig, 0)
|
matchedTasks := make([]model.TaskCenterTaskConfig, 0)
|
||||||
for _, task := range bundle.Tasks {
|
if bundle != nil {
|
||||||
if strings.EqualFold(task.ConditionType, eventType) {
|
for _, task := range bundle.Tasks {
|
||||||
matchedTasks = append(matchedTasks, task)
|
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())
|
resultReason := ""
|
||||||
rawJSON := ""
|
if bundle == nil {
|
||||||
if len(req.Payload) > 0 {
|
resultReason = "task_center_disabled"
|
||||||
if bytes, marshalErr := json.Marshal(req.Payload); marshalErr == nil {
|
} else if len(matchedTasks) == 0 {
|
||||||
rawJSON = string(bytes)
|
resultReason = "no_matched_task"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := 0
|
updated := 0
|
||||||
|
duplicate := false
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var duplicateCount int64
|
inserted, err := insertTaskEventDedupTx(tx, sysOrigin, eventID, eventType, userID)
|
||||||
if err := tx.Model(&model.TaskCenterEvent{}).
|
if err != nil {
|
||||||
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
|
|
||||||
Count(&duplicateCount).Error; err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if duplicateCount > 0 {
|
if !inserted {
|
||||||
|
duplicate = true
|
||||||
return nil
|
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 {
|
for _, task := range matchedTasks {
|
||||||
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, occurredAt)
|
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, occurredAt)
|
||||||
progress, err := s.loadOrCreateProgressTx(tx, sysOrigin, userID, task, cycleKey)
|
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 {
|
if err := tx.Save(&progress).Error; err != nil {
|
||||||
return err
|
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++
|
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 {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := ""
|
if duplicate {
|
||||||
if updated == 0 {
|
return &EventResponse{Processed: false, Updated: 0, Reason: "duplicate_event"}, nil
|
||||||
reason = "no_updated_task"
|
|
||||||
}
|
}
|
||||||
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) {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"chatapp3-golang/internal/common"
|
"chatapp3-golang/internal/common"
|
||||||
"chatapp3-golang/internal/config"
|
"chatapp3-golang/internal/config"
|
||||||
@ -25,6 +27,8 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
|
|||||||
&model.TaskCenterConditionJumpConfig{},
|
&model.TaskCenterConditionJumpConfig{},
|
||||||
&model.TaskCenterUserProgress{},
|
&model.TaskCenterUserProgress{},
|
||||||
&model.TaskCenterEvent{},
|
&model.TaskCenterEvent{},
|
||||||
|
&model.TaskCenterEventDedup{},
|
||||||
|
&model.TaskCenterEventArchiveOutbox{},
|
||||||
&model.TaskCenterClaimRecord{},
|
&model.TaskCenterClaimRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("auto migrate: %v", err)
|
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) {
|
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
||||||
java := &taskCenterTestGateway{events: map[string]bool{}}
|
java := &taskCenterTestGateway{events: map[string]bool{}}
|
||||||
service, _ := newTestService(t, java)
|
service, db := newTestService(t, java)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
configResp, err := service.SaveConfig(ctx, SaveConfigRequest{
|
configResp, err := service.SaveConfig(ctx, SaveConfigRequest{
|
||||||
@ -92,6 +96,46 @@ func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
|||||||
if !eventResp.Processed || eventResp.Updated != 1 {
|
if !eventResp.Processed || eventResp.Updated != 1 {
|
||||||
t.Fatalf("ReceiveEvent() resp = %+v", eventResp)
|
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)
|
tasks, err = service.ListTasks(ctx, user)
|
||||||
if err != nil {
|
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) {
|
func TestConditionJumpConfigAppliesToTasks(t *testing.T) {
|
||||||
service, _ := newTestService(t, nil)
|
service, _ := newTestService(t, nil)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"chatapp3-golang/internal/config"
|
"chatapp3-golang/internal/config"
|
||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
|
|
||||||
|
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,6 +33,15 @@ const (
|
|||||||
ClaimStatusSuccess = "SUCCESS"
|
ClaimStatusSuccess = "SUCCESS"
|
||||||
ClaimStatusFailed = "FAILED"
|
ClaimStatusFailed = "FAILED"
|
||||||
|
|
||||||
|
DedupStatusProcessing = "PROCESSING"
|
||||||
|
DedupStatusSuccess = "SUCCESS"
|
||||||
|
DedupStatusIgnored = "IGNORED"
|
||||||
|
|
||||||
|
ArchiveStatusPending = "PENDING"
|
||||||
|
ArchiveStatusProcessing = "PROCESSING"
|
||||||
|
ArchiveStatusUploaded = "UPLOADED"
|
||||||
|
ArchiveStatusFailed = "FAILED"
|
||||||
|
|
||||||
JumpTypeRechargeInApp = "RECHARGE_IN_APP"
|
JumpTypeRechargeInApp = "RECHARGE_IN_APP"
|
||||||
|
|
||||||
defaultTaskCenterTimezone = "Asia/Riyadh"
|
defaultTaskCenterTimezone = "Asia/Riyadh"
|
||||||
@ -54,10 +64,11 @@ type taskCenterJavaGateway interface {
|
|||||||
|
|
||||||
// Service 负责任务配置、进度、领取和事件入库。
|
// Service 负责任务配置、进度、领取和事件入库。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
db taskCenterDB
|
db taskCenterDB
|
||||||
java taskCenterJavaGateway
|
java taskCenterJavaGateway
|
||||||
location *time.Location
|
location *time.Location
|
||||||
|
rocketConsumer rmq.PushConsumer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
|
||||||
@ -223,23 +234,6 @@ type ClaimRecordView struct {
|
|||||||
UpdateTime string `json:"updateTime"`
|
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
|
type flexibleInt64 int64
|
||||||
|
|
||||||
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
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")
|
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
|
var mapping model.VoiceRoomRegionCountry
|
||||||
err = s.db.WithContext(ctx).
|
err = s.db.WithContext(ctx).
|
||||||
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).
|
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) {
|
func TestImmediatePacketSendsRoomIMMessage(t *testing.T) {
|
||||||
service, _, _ := newVoiceRoomRedPacketTestService(t)
|
service, _, _ := newVoiceRoomRedPacketTestService(t)
|
||||||
fakeIM := &fakeIMGateway{}
|
fakeIM := &fakeIMGateway{}
|
||||||
@ -410,6 +442,19 @@ func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrig
|
|||||||
return "@region-" + strings.ToLower(regionCode), nil
|
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 {
|
type fakeWalletGateway struct {
|
||||||
balance map[int64]int64
|
balance map[int64]int64
|
||||||
events map[string]struct{}
|
events map[string]struct{}
|
||||||
|
|||||||
@ -96,16 +96,21 @@ type roomProfileGateway interface {
|
|||||||
MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error)
|
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 承接语音房红包配置、发送、领取、在线态和退款补偿。
|
// Service 承接语音房红包配置、发送、领取、在线态和退款补偿。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
db voiceRoomRedPacketDB
|
db voiceRoomRedPacketDB
|
||||||
redis redis.Cmdable
|
redis redis.Cmdable
|
||||||
wallet walletGateway
|
wallet walletGateway
|
||||||
im imGateway
|
im imGateway
|
||||||
regionIM regionIMGateway
|
regionIM regionIMGateway
|
||||||
roomProfiles roomProfileGateway
|
roomProfiles roomProfileGateway
|
||||||
notifier *redisNotifier
|
regionResolver regionResolver
|
||||||
|
notifier *redisNotifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, wallet walletGateway, regionIM regionIMGateway, roomProfiles ...roomProfileGateway) *Service {
|
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
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetRegionResolver(resolver regionResolver) {
|
||||||
|
if s != nil {
|
||||||
|
s.regionResolver = resolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigResponse struct {
|
type ConfigResponse struct {
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
ID int64 `json:"id,string"`
|
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