Compare commits

...

7 Commits

Author SHA1 Message Date
hy001
171ba88a2e 经理中心以及币安充值 2026-05-12 14:08:46 +08:00
hy001
47a7725b33 fix: normalize lingxian launch token 2026-05-12 11:19:40 +08:00
hy001
ab8f711169 Merge branch 'main' of gitea.haiyihy.com:hy/chatapp3-golang 2026-05-11 18:47:18 +08:00
hy001
946c3789b1 经理中心 2026-05-11 18:47:16 +08:00
ZuoZuo
df7446fb8c Stop writing task center event rows 2026-05-10 02:43:07 +08:00
ZuoZuo
d39840a6ed Add task center MQ consumer and COS archive outbox 2026-05-10 02:05:01 +08:00
hy001
dd2f4ea6c5 修复区域缓存 2026-05-09 18:01:08 +08:00
42 changed files with 5066 additions and 191 deletions

View File

@ -14,12 +14,14 @@ import (
"chatapp3-golang/internal/router"
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/managercenter"
"chatapp3-golang/internal/service/rechargeagency"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/regionimgroup"
@ -44,6 +46,7 @@ func main() {
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
@ -53,11 +56,13 @@ func main() {
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways)
baishunService.SetTaskEventReporter(taskCenterService)
gameOpenService.SetTaskEventReporter(taskCenterService)
@ -71,6 +76,12 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
log.Fatalf("start task center archive worker failed: %v", err)
}
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
gameProviders := gameprovider.NewRegistry(
@ -81,10 +92,12 @@ func main() {
AppPopup: appPopupService,
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameProviders: gameProviders,
LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService,
HostCenter: hostCenterService,
RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService,

View File

@ -4,6 +4,7 @@ import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/weekstar"
"context"
@ -24,8 +25,10 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer workerCancel()
@ -36,6 +39,12 @@ func main() {
if err := weekStarService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start week star message consumer failed: %v", err)
}
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
log.Fatalf("start task center archive worker failed: %v", err)
}
voiceRoomRedPacketService.StartRefundWorker(workerCtx)
log.Printf("chatapp3-golang consumer started")

5
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dchest/siphash v1.2.3 // indirect
@ -32,6 +33,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@ -42,12 +44,15 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/fastrand v1.1.0 // indirect

16
go.sum
View File

@ -49,6 +49,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
@ -94,6 +96,7 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -132,6 +135,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -140,6 +145,7 @@ github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -179,11 +185,15 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@ -201,6 +211,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -214,6 +225,11 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.73 h1:uFfgp1A7cQaAGR6QP9DsIkoEQ67b8ewj5r1RV6XB540=
github.com/tencentyun/cos-go-sdk-v5 v0.7.73/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=

View File

@ -25,6 +25,7 @@ type Config struct {
Lingxian LingxianConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
}
// HTTPConfig 保存 HTTP 服务运行配置。
@ -88,6 +89,34 @@ type TaskCenterConfig struct {
DefaultSysOrigin string
Timezone string
InternalEventTTL time.Duration
MQ TaskCenterMQConfig
Archive TaskCenterArchiveConfig
}
// TaskCenterMQConfig 保存任务中心事件 MQ 消费配置。
type TaskCenterMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TaskCenterArchiveConfig 保存任务中心原始事件 COS 归档配置。
type TaskCenterArchiveConfig struct {
Enabled bool
Bucket string
Region string
SecretID string
SecretKey string
Prefix string
BatchSize int
FlushSeconds int
Workers int
}
// WeekStarConfig 保存周榜模块配置。
@ -176,6 +205,11 @@ type LuckyGiftConfig struct {
Providers []LuckyGiftProviderConfig
}
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
type BinanceRechargeConfig struct {
BaseURL string
}
type LuckyGiftProviderConfig struct {
StandardID int64 `json:"standardId"`
URL string `json:"url"`
@ -193,6 +227,9 @@ func Load() Config {
weekStarRocketMQEnabledDefault := strings.TrimSpace(weekStarRocketMQEndpoint) != "" &&
strings.TrimSpace(weekStarRocketMQAccessKey) != "" &&
strings.TrimSpace(weekStarRocketMQAccessSecret) != ""
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
return Config{
HTTP: HTTPConfig{
@ -215,7 +252,7 @@ func Load() Config {
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"),
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL", "LIKEI_GATEWAY_ROUTE_ORDER_URI"}, "http://127.0.0.1:2600"),
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
},
Worker: WorkerConfig{
@ -255,6 +292,28 @@ func Load() Config {
[]string{"CHATAPP_TASK_CENTER_EVENT_TTL_HOURS", "TASK_CENTER_EVENT_TTL_HOURS"},
72,
)) * time.Hour,
MQ: TaskCenterMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_MQ_ENABLED", "TASK_CENTER_EVENT_MQ_ENABLED"}, false),
Endpoint: taskCenterMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_NAMESPACE", "TASK_CENTER_EVENT_MQ_NAMESPACE"}, ""),
AccessKey: taskCenterMQAccessKey,
AccessSecret: taskCenterMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_SECURITY_TOKEN", "TASK_CENTER_EVENT_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_CONSUMER_GROUP", "TASK_CENTER_EVENT_MQ_CONSUMER_GROUP"}, "task-center-progress"),
Topic: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TOPIC", "TASK_CENTER_EVENT_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_TAG", "TASK_CENTER_EVENT_MQ_TAG"}, "task_center_event"),
},
Archive: TaskCenterArchiveConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "TASK_CENTER_ARCHIVE_ENABLED"}, false),
Bucket: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_BUCKET", "TASK_CENTER_ARCHIVE_COS_BUCKET", "LIKEI_TENCENT_COS_BUCKET"}, ""),
Region: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_REGION", "TASK_CENTER_ARCHIVE_COS_REGION", "LIKEI_TENCENT_COS_REGION"}, ""),
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
},
},
WeekStar: WeekStarConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
@ -371,6 +430,9 @@ func Load() Config {
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
Providers: loadLuckyGiftConfigs(),
},
BinanceRecharge: BinanceRechargeConfig{
BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"),
},
}
}

View File

@ -81,6 +81,46 @@ func TestLoadWeekStarRocketMQExplicitDisableOverridesCredentials(t *testing.T) {
}
}
func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_MQ_ENABLED", "true")
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
cfg := Load()
if !cfg.TaskCenter.MQ.Enabled {
t.Fatalf("expected task center mq enabled")
}
if got := cfg.TaskCenter.MQ.Endpoint; got != "rmq.example.com:8081" {
t.Fatalf("task center mq endpoint = %q", got)
}
if got := cfg.TaskCenter.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
t.Fatalf("task center mq topic = %q", got)
}
if got := cfg.TaskCenter.MQ.Tag; got != "task_center_event" {
t.Fatalf("task center mq tag = %q", got)
}
}
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")
t.Setenv("LIKEI_TENCENT_COS_REGION", "ap-test")
t.Setenv("LIKEI_TENCENT_COS_SECRET_ID", "secret-id")
t.Setenv("LIKEI_TENCENT_COS_SECRET_KEY", "secret-key")
cfg := Load()
if !cfg.TaskCenter.Archive.Enabled {
t.Fatalf("expected task center archive enabled")
}
if got := cfg.TaskCenter.Archive.Bucket; got != "bucket-123" {
t.Fatalf("task center archive bucket = %q", got)
}
if got := cfg.TaskCenter.Archive.Prefix; got != "task-center-events/v1" {
t.Fatalf("task center archive prefix = %q", got)
}
}
func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) {
t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key")

View File

@ -96,6 +96,16 @@ func (g *Gateways) GivePropsBackpack(ctx context.Context, req GivePropsBackpackR
return g.RewardDispatch.GivePropsBackpack(ctx, req)
}
// GetNobleVIPAbility 透传到贵族 VIP 能力配置接口。
func (g *Gateways) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.RewardQuery.GetNobleVIPAbility(ctx, sourceID)
}
// GetUserMaxNobleVIPAbility 透传到用户当前最高贵族 VIP 能力配置接口。
func (g *Gateways) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
return g.RewardQuery.GetUserMaxNobleVIPAbility(ctx, userID)
}
// SwitchUseProps 透传到道具使用切换接口。
func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID)
@ -126,6 +136,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio
return g.Profile.GetUserRegion(ctx, userID, authorization)
}
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
}
// GetUserProfileByAccount 透传到资料网关。
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account)
@ -146,6 +161,16 @@ func (g *Gateways) RemoveUserProfileCacheAll(ctx context.Context, userID int64)
return g.Profile.RemoveUserProfileCacheAll(ctx, userID)
}
// PunishAccount 透传到账号处罚接口。
func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
return g.Profile.PunishAccount(ctx, req)
}
// UnblockAccount 透传到账号解封接口。
func (g *Gateways) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
return g.Profile.UnblockAccount(ctx, req)
}
// ListCurrentWeekRoomContribution 透传到房间流水网关。
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
@ -191,6 +216,26 @@ func (g *Gateways) PageFreightSellers(ctx context.Context, authorization string,
return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
}
func (g *Gateways) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
return g.Freight.ExistsFreightBalance(ctx, userID)
}
func (g *Gateways) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
return g.Freight.ExistsFreightSeller(ctx, userID)
}
func (g *Gateways) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
return g.Freight.ChangeFreightBalance(ctx, req)
}
func (g *Gateways) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
return g.Freight.AddFreightAgentReview(ctx, req)
}
func (g *Gateways) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
return g.Order.IncreaseFreightRechargeRecord(ctx, userID, amount)
}
// AuthGateway 负责认证相关接口。
type AuthGateway struct {
client *Client
@ -256,6 +301,10 @@ func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, us
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
}
func (g *OrderGateway) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
return g.client.IncreaseFreightRechargeRecord(ctx, userID, amount)
}
// RewardDispatchGateway 负责活动奖励发放接口。
type RewardDispatchGateway struct {
client *Client
@ -301,6 +350,16 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
return g.client.GetRewardGroupDetail(ctx, groupID)
}
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.client.GetNobleVIPAbility(ctx, sourceID)
}
// GetUserMaxNobleVIPAbility 查询用户当前最高贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
return g.client.GetUserMaxNobleVIPAbility(ctx, userID)
}
// ProfileGateway 负责用户资料相关接口。
type ProfileGateway struct {
client *Client
@ -316,6 +375,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author
return g.client.GetUserRegion(ctx, userID, authorization)
}
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
}
// GetUserProfileByAccount 根据账号查询用户资料。
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.client.GetUserProfileByAccount(ctx, sysOrigin, account)
@ -336,6 +400,16 @@ func (g *ProfileGateway) RemoveUserProfileCacheAll(ctx context.Context, userID i
return g.client.RemoveUserProfileCacheAll(ctx, userID)
}
// PunishAccount 处罚用户账号。
func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
return g.client.PunishAccount(ctx, req)
}
// UnblockAccount 解封用户账号。
func (g *ProfileGateway) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
return g.client.UnblockAccount(ctx, req)
}
// RoomGateway 负责房间资料和房间流水相关接口。
type RoomGateway struct {
client *Client
@ -400,3 +474,19 @@ type FreightGateway struct {
func (g *FreightGateway) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
return g.client.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
}
func (g *FreightGateway) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
return g.client.ExistsFreightBalance(ctx, userID)
}
func (g *FreightGateway) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
return g.client.ExistsFreightSeller(ctx, userID)
}
func (g *FreightGateway) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
return g.client.ChangeFreightBalance(ctx, req)
}
func (g *FreightGateway) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
return g.client.AddFreightAgentReview(ctx, req)
}

View File

@ -12,14 +12,17 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/config"
)
type Client struct {
cfg config.Config
httpClient *http.Client
cfg config.Config
httpClient *http.Client
regionConfigCacheMu sync.Mutex
regionConfigCacheMap map[string]regionConfigCacheEntry
}
type UserCredential struct {
@ -39,15 +42,16 @@ type ConsoleAccount struct {
}
type UserProfile struct {
ID Int64Value `json:"id"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
CountryID Int64Value `json:"countryId"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
RegionCode string `json:"regionCode"`
OriginSys string `json:"originSys"`
ID Int64Value `json:"id"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
CountryID Int64Value `json:"countryId"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
RegionCode string `json:"regionCode"`
OriginSys string `json:"originSys"`
AccountStatus string `json:"accountStatus"`
}
type UserRegion struct {
@ -55,6 +59,21 @@ type UserRegion struct {
RegionCode string `json:"regionCode"`
}
type RegionConfig struct {
RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"`
RegionName string `json:"regionName"`
CountryCodes string `json:"countryCodes"`
Del bool `json:"del"`
}
type regionConfigCacheEntry struct {
expiresAt time.Time
items []RegionConfig
}
const regionConfigCacheTTL = time.Minute
type RoomContributionActivityCount struct {
ID string `json:"id"`
RoomID Int64Value `json:"roomId"`
@ -141,6 +160,31 @@ type GivePropsBackpackRequest struct {
OpUser int64 `json:"opUser,omitempty"`
}
type PropsNobleVIPAbility struct {
ID Int64Value `json:"id"`
VIPType string `json:"vipType"`
VIPLevel int `json:"vipLevel"`
AdminNumber int `json:"adminNumber"`
RoomMaxMember int `json:"roomMaxMember"`
AvatarFrameID Int64Value `json:"avatarFrameId"`
CarID Int64Value `json:"carId"`
ChatBubbleID Int64Value `json:"chatBubbleId"`
DataCardID Int64Value `json:"dataCardId"`
}
type PunishAccountRequest struct {
BeOptUserID int64 `json:"beOptUserId"`
OptUserID int64 `json:"optUserId"`
Status string `json:"status"`
Description string `json:"description"`
}
type UnblockAccountRequest struct {
BeOptUserID int64 `json:"beOptUserId"`
OptUserID int64 `json:"optUserId"`
Description string `json:"description"`
}
type SendActivityRewardRequest struct {
TrackID int64 `json:"trackId"`
Origin string `json:"origin"`
@ -258,6 +302,25 @@ type FreightSellerProfile struct {
OriginSys string `json:"originSys"`
}
type FreightBalanceChangeRequest struct {
SysOrigin string `json:"sysOrigin"`
UserID int64 `json:"userId"`
AcceptUserID int64 `json:"acceptUserId"`
Type string `json:"type"`
AcceptAmount string `json:"acceptAmount"`
OrderAmount string `json:"orderAmount"`
USDAmount string `json:"usdAmount"`
Origin string `json:"origin"`
Remark string `json:"remark"`
RechargeType string `json:"rechargeType"`
CreateUser int64 `json:"createUser"`
}
type FreightAgentReviewRequest struct {
SysOrigin string `json:"sysOrigin"`
UserID int64 `json:"userId"`
}
type freightSellerPageResponse struct {
Status *bool `json:"status"`
ErrorMsg string `json:"errorMsg"`
@ -370,6 +433,7 @@ func New(cfg config.Config) *Client {
httpClient: &http.Client{
Timeout: cfg.HTTP.Timeout,
},
regionConfigCacheMap: map[string]regionConfigCacheEntry{},
}
}
@ -576,6 +640,26 @@ func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackReq
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props/client/giveProps", req, nil, nil)
}
func (c *Client) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getAbilityDTO?sourceId=" +
url.QueryEscape(strconv.FormatInt(sourceID, 10))
var resp resultResponse[PropsNobleVIPAbility]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return PropsNobleVIPAbility{}, err
}
return resp.Body, nil
}
func (c *Client) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getUserMaxAbilityDTO?userId=" +
url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[PropsNobleVIPAbility]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return PropsNobleVIPAbility{}, err
}
return resp.Body, nil
}
func (c *Client) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" +
url.QueryEscape(strconv.FormatInt(userID, 10)) +
@ -687,6 +771,94 @@ func (c *Client) PageFreightSellers(ctx context.Context, authorization string, s
return resp.FreightSellerPage, nil
}
func (c *Client) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
values := url.Values{}
values.Set("userId", strconv.FormatInt(userID, 10))
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsBalance?" + values.Encode()
var resp resultResponse[bool]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return false, err
}
if resp.Success != nil && !*resp.Success {
return false, errors.New(strings.TrimSpace(resp.Message))
}
return resp.Body, nil
}
func (c *Client) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
values := url.Values{}
values.Set("userId", strconv.FormatInt(userID, 10))
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsSeller?" + values.Encode()
var resp resultResponse[bool]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return false, err
}
if resp.Success != nil && !*resp.Success {
return false, errors.New(strings.TrimSpace(resp.Message))
}
return resp.Body, nil
}
func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/changeBalance"
var resp resultResponse[any]
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "freight balance change failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/addFreightAgentReview"
var resp resultResponse[any]
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "freight agent review add failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
if userID <= 0 {
return fmt.Errorf("userId is required")
}
values := url.Values{}
values.Set("userId", strconv.FormatInt(userID, 10))
values.Set("amount", strings.TrimSpace(amount))
endpoint := strings.TrimRight(c.cfg.Java.OrderBaseURL, "/") + "/user/freight/recharge-record/client/inrNowMonthAmount?" + values.Encode()
var resp resultResponse[DecimalString]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "freight recharge record increase failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[UserProfile]
@ -715,6 +887,75 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil
}
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {
return "", nil
}
configs, err := c.ListRegionConfigs(ctx, sysOrigin)
if err != nil {
return "", err
}
for _, item := range configs {
if item.Del {
continue
}
if !regionConfigContainsCountryCode(item.CountryCodes, countryCode) {
continue
}
regionCode := strings.TrimSpace(item.RegionCode)
if regionCode != "" {
return regionCode, nil
}
}
return "", nil
}
func (c *Client) ListRegionConfigs(ctx context.Context, sysOrigin string) ([]RegionConfig, error) {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
sysOrigin = "LIKEI"
}
now := time.Now()
c.regionConfigCacheMu.Lock()
if entry, ok := c.regionConfigCacheMap[sysOrigin]; ok && now.Before(entry.expiresAt) {
items := append([]RegionConfig(nil), entry.items...)
c.regionConfigCacheMu.Unlock()
return items, nil
}
c.regionConfigCacheMu.Unlock()
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/region-config/client/listByCondition"
var resp resultResponse[[]RegionConfig]
if err := c.postJSON(ctx, endpoint, map[string]string{"sysOrigin": sysOrigin}, nil, &resp); err != nil {
return nil, err
}
items := append([]RegionConfig(nil), resp.Body...)
c.regionConfigCacheMu.Lock()
if c.regionConfigCacheMap == nil {
c.regionConfigCacheMap = map[string]regionConfigCacheEntry{}
}
c.regionConfigCacheMap[sysOrigin] = regionConfigCacheEntry{
expiresAt: now.Add(regionConfigCacheTTL),
items: append([]RegionConfig(nil), items...),
}
c.regionConfigCacheMu.Unlock()
return items, nil
}
func regionConfigContainsCountryCode(countryCodes string, countryCode string) bool {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {
return false
}
for _, item := range strings.Split(countryCodes, ",") {
if strings.ToUpper(strings.TrimSpace(item)) == countryCode {
return true
}
}
return false
}
func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" +
url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account))
@ -838,6 +1079,14 @@ func (c *Client) RemoveUserProfileCacheAll(ctx context.Context, userID int64) er
return c.getJSON(ctx, endpoint, nil, nil)
}
func (c *Client) PunishAccount(ctx context.Context, req PunishAccountRequest) error {
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/punishment", req, nil, nil)
}
func (c *Client) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/unblock", req, nil, nil)
}
func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
var resp resultResponse[map[string]int64]

View File

@ -1,8 +1,14 @@
package integration
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/config"
)
func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
@ -29,3 +35,45 @@ func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) {
t.Fatalf("Amount = %q, want 1.60", item.Amount)
}
}
func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != http.MethodPost || r.URL.Path != "/region-config/client/listByCondition" {
t.Fatalf("request = %s %s, want POST /region-config/client/listByCondition", r.Method, r.URL.Path)
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload["sysOrigin"] != "LIKEI" {
t.Fatalf("sysOrigin = %q, want LIKEI", payload["sysOrigin"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":[{"regionCode":"中东区","sysOrigin":"LIKEI","countryCodes":"JO, EG, KSA","del":false},{"regionCode":"US","sysOrigin":"LIKEI","countryCodes":"US","del":true}]}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.ResolveRegionCodeByCountryCode(context.Background(), " likei ", " ksa ")
if err != nil {
t.Fatalf("ResolveRegionCodeByCountryCode() error = %v", err)
}
if got != "中东区" {
t.Fatalf("region = %q, want 中东区", got)
}
got, err = client.ResolveRegionCodeByCountryCode(context.Background(), "LIKEI", "eg")
if err != nil {
t.Fatalf("ResolveRegionCodeByCountryCode() cached error = %v", err)
}
if got != "中东区" {
t.Fatalf("cached region = %q, want 中东区", got)
}
if calls != 1 {
t.Fatalf("region config calls = %d, want cached 1", calls)
}
}

View 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"
}

View File

@ -99,6 +99,43 @@ type TaskCenterEvent struct {
func (TaskCenterEvent) TableName() string { return "task_center_event" }
// TaskCenterEventDedup 保存业务事件幂等记录。
type TaskCenterEventDedup struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_event_dedup,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_event_dedup,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
Status string `gorm:"column:status;size:32;index:idx_task_center_event_dedup_status"`
FailureReason string `gorm:"column:failure_reason;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_event_dedup_create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup" }
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
type TaskCenterEventArchiveOutbox struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at"`
RawJSON string `gorm:"column:raw_json;type:text"`
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1"`
RetryCount int `gorm:"column:retry_count"`
COSKey string `gorm:"column:cos_key;size:512"`
FailureReason string `gorm:"column:failure_reason;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
UpdateTime time.Time `gorm:"column:update_time"`
ArchivedAt *time.Time `gorm:"column:archived_at"`
}
func (TaskCenterEventArchiveOutbox) TableName() string {
return "task_center_event_archive_outbox"
}
// TaskCenterClaimRecord 保存任务奖励领取记录。
type TaskCenterClaimRecord struct {
ID int64 `gorm:"column:id;primaryKey"`

View 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)
})
}

View 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)
})
}

View File

@ -8,12 +8,14 @@ import (
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/managercenter"
"chatapp3-golang/internal/service/rechargeagency"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/regionimgroup"
@ -33,10 +35,12 @@ type Services struct {
AppPopup *apppopup.Service
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service
HostCenter *hostcenter.Service
RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service
@ -65,6 +69,7 @@ func NewRouter(
registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
@ -79,6 +84,7 @@ func NewRouter(
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
return engine
}

View File

@ -130,21 +130,4 @@ func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient
}
writeOK(c, resp)
})
adminGroup.GET("/event/page", func(c *gin.Context) {
resp, err := service.PageEvents(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("taskCategory"),
taskcenter.ParseInt64(c.Query("userId")),
c.Query("taskCode"),
c.Query("eventType"),
int(taskcenter.ParseInt64(c.Query("cursor"))),
int(taskcenter.ParseInt64(c.Query("limit"))),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View 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
}

View 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 ""
}
}

View 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
}

View 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
}

View 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")
}
}

View 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]
}

View File

@ -288,7 +288,7 @@ func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string {
}
func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string {
return defaultIfBlank(cfg.TestToken, user.Token)
return trimTokenPadding(defaultIfBlank(cfg.TestToken, user.Token))
}
func launchRoomID(cfg runtimeConfig, req gameprovider.LaunchRequest) string {
@ -307,6 +307,10 @@ func launchLang(req gameprovider.LaunchRequest) string {
return defaultLaunchLang
}
func trimTokenPadding(token string) string {
return strings.TrimRight(strings.TrimSpace(token), "=")
}
func launchSign(cfg runtimeConfig, gameID, uid, token, roomID string) string {
sum := md5.Sum([]byte(gameID + uid + token + roomID + strings.TrimSpace(cfg.AppKey)))
return hex.EncodeToString(sum[:])

View File

@ -355,7 +355,7 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
Active: true,
AppKey: "test-key",
TestUID: "test-uid",
TestToken: "test-token",
TestToken: "test-token==",
TestRoomID: "test-room",
CreateTime: now,
UpdateTime: now,
@ -466,6 +466,9 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
if got := query.Get("token"); got != "test-token" {
t.Fatalf("query token = %q, want test-token", got)
}
if got := query.Get("sign"); got != launchSign(runtimeConfig{AppKey: "test-key"}, "1", "test-uid", "test-token", "test-room") {
t.Fatalf("query sign = %q, want sign with trimmed token", got)
}
if got := query.Get("lang"); got != defaultLaunchLang {
t.Fatalf("query lang = %q, want %s", got, defaultLaunchLang)
}
@ -478,6 +481,9 @@ func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) {
if got := launch.LaunchConfig.(map[string]any)["roomid"]; got != "test-room" {
t.Fatalf("launchConfig.roomid = %v, want test-room", got)
}
if got := launch.LaunchConfig.(map[string]any)["token"]; got != "test-token" {
t.Fatalf("launchConfig.token = %v, want trimmed token", got)
}
if got := launch.LaunchConfig.(map[string]any)["lang"]; got != defaultLaunchLang {
t.Fatalf("launchConfig.lang = %v, want %s", got, defaultLaunchLang)
}

View 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,
}
}

View 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
}

View 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)
}

View 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
}

View File

@ -25,6 +25,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI
return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing")
}
if s != nil && s.regionResolver != nil {
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
if err == nil {
if normalized := normalizeRegionCode(regionCode); normalized != "" {
return normalized, nil
}
}
}
var mapping model.VoiceRoomRegionCountry
err = s.db.WithContext(ctx).
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).

View File

@ -133,6 +133,42 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) {
}
}
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{
regions: map[string]string{"LIKEI|SA": "中东区"},
}
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3003,
SysOrigin: "LIKEI",
RegionCode: "中东区",
RegionName: "Middle East",
GroupID: "@region-middle-east",
GroupName: "region-middle-east",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
resp, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
SenderUserID: flexibleInt64(1001),
Type: "SEND_GIFT",
Data: map[string]any{"roomId": "9001"},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
if resp.RegionCode != "中东区" || resp.GroupID != "@region-middle-east" {
t.Fatalf("SendRegionBroadcast() response = %+v, want Java region group", resp)
}
}
func newRegionIMGroupTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -191,3 +227,16 @@ func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string
g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body})
return nil
}
type fakeRegionResolver struct {
regions map[string]string
err error
}
func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) {
if g.err != nil {
return "", g.err
}
key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode))
return strings.TrimSpace(g.regions[key]), nil
}

View File

@ -36,18 +36,28 @@ type imGateway interface {
SendCustomGroupMessage(ctx context.Context, groupID string, body any) error
}
// Service 负责区域通用 IM 群组创建、查询和区域广播。
type Service struct {
cfg config.Config
db dbHandle
im imGateway
type regionResolver interface {
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
}
func NewService(cfg config.Config, db dbHandle) *Service {
// Service 负责区域通用 IM 群组创建、查询和区域广播。
type Service struct {
cfg config.Config
db dbHandle
im imGateway
regionResolver regionResolver
}
func NewService(cfg config.Config, db dbHandle, resolvers ...regionResolver) *Service {
var resolver regionResolver
if len(resolvers) > 0 {
resolver = resolvers[0]
}
return &Service{
cfg: cfg,
db: db,
im: tencentim.NewClient(cfg.TencentIM),
cfg: cfg,
db: db,
im: tencentim.NewClient(cfg.TencentIM),
regionResolver: resolver,
}
}

View File

@ -233,45 +233,6 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCa
return &PageResponse[ClaimRecordView]{Records: records, Total: total, Current: cursor, Size: limit}, nil
}
// PageEvents 分页查询事件流水。
func (s *Service) PageEvents(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, eventType string, cursor int, limit int) (*PageResponse[EventView], error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
category := normalizeCategory(taskCategory)
if err := validateCategory(category); err != nil {
return nil, err
}
cursor, limit = NormalizePage(cursor, limit)
query := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}).
Where("sys_origin = ? AND task_category = ?", sysOrigin, category)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" {
query = query.Where("task_code = ?", code)
}
if normalizedEventType := strings.ToUpper(strings.TrimSpace(eventType)); normalizedEventType != "" {
query = query.Where("event_type = ?", normalizedEventType)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.TaskCenterEvent
if err := query.Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
records := make([]EventView, 0, len(rows))
for _, row := range rows {
records = append(records, eventView(row))
}
return &PageResponse[EventView]{Records: records, Total: total, Current: cursor, Size: limit}, nil
}
func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) {
if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "tasks is required")
@ -638,25 +599,6 @@ func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView {
}
}
func eventView(row model.TaskCenterEvent) EventView {
return EventView{
ID: row.ID,
SysOrigin: row.SysOrigin,
EventID: row.EventID,
EventType: row.EventType,
UserID: row.UserID,
TaskID: row.TaskID,
TaskCode: row.TaskCode,
TaskCategory: row.TaskCategory,
CycleKey: row.CycleKey,
DeltaValue: row.DeltaValue,
CompletedValue: row.CompletedValue,
OccurredAt: formatDateTime(row.OccurredAt),
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
}
}
func taskCenterSysOriginBySavedID(ctx context.Context, db taskCenterDB, configID int64, fallback string) string {
if configID <= 0 {
return fallback

View 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
}

View File

@ -13,6 +13,7 @@ import (
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ReportSimpleEvent 供 Go 内部业务模块上报任务进度事件。
@ -23,7 +24,7 @@ func (s *Service) ReportSimpleEvent(ctx context.Context, sysOrigin, eventID, eve
// ReportSimpleEventAt 和 ReportSimpleEvent 相同,但允许 MQ 等异步事件传入真实发生时间。
func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt time.Time, payload map[string]any) error {
_, err := s.ReceiveEvent(ctx, EventRequest{
_, err := s.ProcessEvent(ctx, EventRequest{
SysOrigin: sysOrigin,
EventID: eventID,
EventType: eventType,
@ -40,6 +41,11 @@ func (s *Service) ReportSimpleEventAt(ctx context.Context, sysOrigin, eventID, e
// ReceiveEvent 接收后端业务事件并更新命中的任务进度。
func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventResponse, error) {
return s.ProcessEvent(ctx, req)
}
// ProcessEvent 统一处理 HTTP 和 MQ 上报的任务事件。
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventResponse, error) {
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
eventID := strings.TrimSpace(req.EventID)
if eventID == "" {
@ -59,55 +65,50 @@ func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventRes
if req.DeltaValue <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_delta_value", "deltaValue must be greater than 0")
}
var existingCount int64
if err := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}).
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
Count(&existingCount).Error; err != nil {
return nil, err
}
if existingCount > 0 {
return &EventResponse{Processed: false, Updated: 0, Reason: "duplicate_event"}, nil
}
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
receivedAt := time.Now()
rawJSON := buildArchiveRawJSON(req, sysOrigin, eventID, eventType, userID, req.DeltaValue, occurredAt, receivedAt, "", "")
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
if err != nil {
return nil, err
}
if bundle == nil {
return &EventResponse{Processed: false, Updated: 0, Reason: "task_center_disabled"}, nil
}
matchedTasks := make([]model.TaskCenterTaskConfig, 0)
for _, task := range bundle.Tasks {
if strings.EqualFold(task.ConditionType, eventType) {
matchedTasks = append(matchedTasks, task)
if bundle != nil {
for _, task := range bundle.Tasks {
if strings.EqualFold(task.ConditionType, eventType) {
matchedTasks = append(matchedTasks, task)
}
}
}
if len(matchedTasks) == 0 {
return &EventResponse{Processed: false, Updated: 0, Reason: "no_matched_task"}, nil
}
occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
rawJSON := ""
if len(req.Payload) > 0 {
if bytes, marshalErr := json.Marshal(req.Payload); marshalErr == nil {
rawJSON = string(bytes)
}
resultReason := ""
if bundle == nil {
resultReason = "task_center_disabled"
} else if len(matchedTasks) == 0 {
resultReason = "no_matched_task"
}
updated := 0
duplicate := false
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var duplicateCount int64
if err := tx.Model(&model.TaskCenterEvent{}).
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
Count(&duplicateCount).Error; err != nil {
inserted, err := insertTaskEventDedupTx(tx, sysOrigin, eventID, eventType, userID)
if err != nil {
return err
}
if duplicateCount > 0 {
if !inserted {
duplicate = true
return nil
}
if err := createTaskEventArchiveOutboxTx(tx, sysOrigin, eventID, eventType, userID, occurredAt, rawJSON); err != nil {
return err
}
if resultReason != "" {
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusIgnored, resultReason)
}
for _, task := range matchedTasks {
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, occurredAt)
progress, err := s.loadOrCreateProgressTx(tx, sysOrigin, userID, task, cycleKey)
@ -128,43 +129,114 @@ func (s *Service) ReceiveEvent(ctx context.Context, req EventRequest) (*EventRes
if err := tx.Save(&progress).Error; err != nil {
return err
}
eventRowID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
eventRow := model.TaskCenterEvent{
ID: eventRowID,
SysOrigin: sysOrigin,
EventID: eventID,
EventType: eventType,
UserID: userID,
TaskID: task.ID,
TaskCode: task.TaskCode,
TaskCategory: task.TaskCategory,
CycleKey: cycleKey,
DeltaValue: req.DeltaValue,
CompletedValue: progress.CompletedValue,
RawJSON: rawJSON,
OccurredAt: occurredAt,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := tx.Create(&eventRow).Error; err != nil {
return err
}
updated++
}
return nil
if updated == 0 {
resultReason = "no_updated_task"
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusIgnored, resultReason)
}
return updateTaskEventDedupTx(tx, sysOrigin, eventID, DedupStatusSuccess, "")
}); err != nil {
return nil, err
}
reason := ""
if updated == 0 {
reason = "no_updated_task"
if duplicate {
return &EventResponse{Processed: false, Updated: 0, Reason: "duplicate_event"}, nil
}
return &EventResponse{Processed: updated > 0, Updated: updated, Reason: reason}, nil
return &EventResponse{Processed: updated > 0, Updated: updated, Reason: resultReason}, nil
}
func insertTaskEventDedupTx(tx *gorm.DB, sysOrigin, eventID, eventType string, userID int64) (bool, error) {
id, err := utils.NextID()
if err != nil {
return false, err
}
now := time.Now()
row := model.TaskCenterEventDedup{
ID: id,
SysOrigin: sysOrigin,
EventID: eventID,
EventType: eventType,
UserID: userID,
Status: DedupStatusProcessing,
CreateTime: now,
UpdateTime: now,
}
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "event_id"}},
DoNothing: true,
}).Create(&row)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func updateTaskEventDedupTx(tx *gorm.DB, sysOrigin, eventID, status, failureReason string) error {
return tx.Model(&model.TaskCenterEventDedup{}).
Where("sys_origin = ? AND event_id = ?", sysOrigin, eventID).
Updates(map[string]any{
"status": status,
"failure_reason": truncateTaskCenterFailure(failureReason),
"update_time": time.Now(),
}).Error
}
func createTaskEventArchiveOutboxTx(tx *gorm.DB, sysOrigin, eventID, eventType string, userID int64, occurredAt time.Time, rawJSON string) error {
id, err := utils.NextID()
if err != nil {
return err
}
now := time.Now()
row := model.TaskCenterEventArchiveOutbox{
ID: id,
SysOrigin: sysOrigin,
EventID: eventID,
EventType: eventType,
UserID: userID,
OccurredAt: occurredAt,
RawJSON: rawJSON,
Status: ArchiveStatusPending,
CreateTime: now,
UpdateTime: now,
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "event_id"}},
DoNothing: true,
}).Create(&row).Error
}
func buildArchiveRawJSON(req EventRequest, sysOrigin, eventID, eventType string, userID, deltaValue int64, occurredAt, receivedAt time.Time, processResult, processReason string) string {
row := map[string]any{
"schema_version": 1,
"sys_origin": sysOrigin,
"event_id": eventID,
"event_type": eventType,
"user_id": userID,
"delta_value": deltaValue,
"occurred_at": occurredAt.UTC().Format(time.RFC3339Nano),
"received_at": receivedAt.UTC().Format(time.RFC3339Nano),
"payload": req.Payload,
}
if processResult != "" {
row["process_result"] = processResult
}
if processReason != "" {
row["process_reason"] = processReason
}
bytes, err := json.Marshal(row)
if err != nil {
return "{}"
}
return string(bytes)
}
func truncateTaskCenterFailure(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 512 {
return value
}
return value[:512]
}
func (s *Service) loadOrCreateProgressTx(tx *gorm.DB, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, cycleKey string) (model.TaskCenterUserProgress, error) {

View 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
}

View File

@ -2,7 +2,9 @@ package taskcenter
import (
"context"
"strings"
"testing"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
@ -25,6 +27,8 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
&model.TaskCenterConditionJumpConfig{},
&model.TaskCenterUserProgress{},
&model.TaskCenterEvent{},
&model.TaskCenterEventDedup{},
&model.TaskCenterEventArchiveOutbox{},
&model.TaskCenterClaimRecord{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
@ -40,7 +44,7 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
java := &taskCenterTestGateway{events: map[string]bool{}}
service, _ := newTestService(t, java)
service, db := newTestService(t, java)
ctx := context.Background()
configResp, err := service.SaveConfig(ctx, SaveConfigRequest{
@ -92,6 +96,46 @@ func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
if !eventResp.Processed || eventResp.Updated != 1 {
t.Fatalf("ReceiveEvent() resp = %+v", eventResp)
}
duplicateResp, err := service.ReceiveEvent(ctx, EventRequest{
SysOrigin: "LIKEI",
EventID: "event-1",
EventType: "MIC_DURATION_SECONDS",
UserID: flexibleInt64(user.UserID),
DeltaValue: 60,
})
if err != nil {
t.Fatalf("ReceiveEvent() duplicate error = %v", err)
}
if duplicateResp.Processed || duplicateResp.Reason != "duplicate_event" {
t.Fatalf("ReceiveEvent() duplicate resp = %+v", duplicateResp)
}
var dedupCount int64
if err := db.Model(&model.TaskCenterEventDedup{}).
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
Count(&dedupCount).Error; err != nil {
t.Fatalf("count dedup: %v", err)
}
if dedupCount != 1 {
t.Fatalf("dedup rows = %d, want 1", dedupCount)
}
var outboxCount int64
if err := db.Model(&model.TaskCenterEventArchiveOutbox{}).
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
Count(&outboxCount).Error; err != nil {
t.Fatalf("count archive outbox: %v", err)
}
if outboxCount != 1 {
t.Fatalf("archive outbox rows = %d, want 1", outboxCount)
}
var legacyEventCount int64
if err := db.Model(&model.TaskCenterEvent{}).
Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1").
Count(&legacyEventCount).Error; err != nil {
t.Fatalf("count legacy task event: %v", err)
}
if legacyEventCount != 0 {
t.Fatalf("legacy task event rows = %d, want 0", legacyEventCount)
}
tasks, err = service.ListTasks(ctx, user)
if err != nil {
@ -196,6 +240,104 @@ func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) {
}
}
func TestProcessEventWithProductionGameEventShape(t *testing.T) {
service, db := newTestService(t, nil)
ctx := context.Background()
now := time.Now()
configRow := model.TaskCenterConfig{
ID: 2052721146352111616,
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "Asia/Riyadh",
CreateTime: now,
UpdateTime: now,
}
if err := db.Create(&configRow).Error; err != nil {
t.Fatalf("create config: %v", err)
}
tasks := []model.TaskCenterTaskConfig{
{
ID: 2053002284639457280,
ConfigID: configRow.ID,
SysOrigin: "LIKEI",
TaskCode: "GAME_200K",
TaskCategory: TaskCategoryDaily,
Enabled: true,
TaskName: "Spend 200K coins in the game.",
TaskDesc: "progress",
ConditionType: EventTypeGameConsumeGold,
TargetValue: 200000,
TargetUnit: "gold",
RewardGold: 500,
JumpType: "ROOM_RANDOM_WITH_MIC",
JumpPage: "/room?gameId=0",
SortOrder: 30,
CreateTime: now,
UpdateTime: now,
},
{
ID: 2053005491679793152,
ConfigID: configRow.ID,
SysOrigin: "LIKEI",
TaskCode: "GAME_40K",
TaskCategory: TaskCategoryNewbie,
Enabled: true,
TaskName: "Spend 40K coins in the game.",
TaskDesc: "progress",
ConditionType: EventTypeGameConsumeGold,
TargetValue: 40000,
TargetUnit: "gold",
RewardGold: 200,
JumpType: "ROOM_RANDOM_WITH_MIC",
SortOrder: 30,
CreateTime: now,
UpdateTime: now,
},
}
if err := db.Create(&tasks).Error; err != nil {
t.Fatalf("create tasks: %v", err)
}
resp, err := service.ProcessEvent(ctx, EventRequest{
SysOrigin: "LIKEI",
EventID: "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930",
EventType: EventTypeGameConsumeGold,
UserID: flexibleInt64(2053169065266049025),
DeltaValue: 100,
OccurredAt: "2026-05-09T20:48:30.291+03:00",
Payload: map[string]any{
"diffMsg": "bet",
"gameId": float64(1067),
"gameRoundId": "20260510000171",
"orderId": "ee2d4218-4f58-406a-bf0f-d6b09f7e9930",
"provider": "BAISHUN",
},
})
if err != nil {
t.Fatalf("ProcessEvent() error = %v", err)
}
if !resp.Processed || resp.Updated != 2 {
t.Fatalf("ProcessEvent() resp = %+v, want processed with two task updates", resp)
}
var progressCount int64
if err := db.Model(&model.TaskCenterUserProgress{}).
Where("sys_origin = ? AND user_id = ? AND completed_value = ?", "LIKEI", int64(2053169065266049025), int64(100)).
Count(&progressCount).Error; err != nil {
t.Fatalf("count progress: %v", err)
}
if progressCount != 2 {
t.Fatalf("progress rows = %d, want 2", progressCount)
}
var outbox model.TaskCenterEventArchiveOutbox
if err := db.Where("sys_origin = ? AND event_id = ?", "LIKEI", "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930").
First(&outbox).Error; err != nil {
t.Fatalf("load archive outbox: %v", err)
}
if outbox.Status != ArchiveStatusPending || !strings.Contains(outbox.RawJSON, "BAISHUN_GAME_CONSUME") {
t.Fatalf("archive outbox = %+v", outbox)
}
}
func TestConditionJumpConfigAppliesToTasks(t *testing.T) {
service, _ := newTestService(t, nil)
ctx := context.Background()

View File

@ -13,6 +13,7 @@ import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
@ -32,6 +33,15 @@ const (
ClaimStatusSuccess = "SUCCESS"
ClaimStatusFailed = "FAILED"
DedupStatusProcessing = "PROCESSING"
DedupStatusSuccess = "SUCCESS"
DedupStatusIgnored = "IGNORED"
ArchiveStatusPending = "PENDING"
ArchiveStatusProcessing = "PROCESSING"
ArchiveStatusUploaded = "UPLOADED"
ArchiveStatusFailed = "FAILED"
JumpTypeRechargeInApp = "RECHARGE_IN_APP"
defaultTaskCenterTimezone = "Asia/Riyadh"
@ -54,10 +64,11 @@ type taskCenterJavaGateway interface {
// Service 负责任务配置、进度、领取和事件入库。
type Service struct {
cfg config.Config
db taskCenterDB
java taskCenterJavaGateway
location *time.Location
cfg config.Config
db taskCenterDB
java taskCenterJavaGateway
location *time.Location
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db taskCenterDB, java taskCenterJavaGateway) *Service {
@ -223,23 +234,6 @@ type ClaimRecordView struct {
UpdateTime string `json:"updateTime"`
}
type EventView struct {
ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"`
EventID string `json:"eventId"`
EventType string `json:"eventType"`
UserID int64 `json:"userId,string"`
TaskID int64 `json:"taskId,string"`
TaskCode string `json:"taskCode"`
TaskCategory string `json:"taskCategory"`
CycleKey string `json:"cycleKey"`
DeltaValue int64 `json:"deltaValue"`
CompletedValue int64 `json:"completedValue"`
OccurredAt string `json:"occurredAt"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
}
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {

View File

@ -108,6 +108,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI
return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing")
}
if s != nil && s.regionResolver != nil {
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
if err == nil {
if normalized := strings.ToUpper(strings.TrimSpace(regionCode)); normalized != "" {
return normalized, nil
}
}
}
var mapping model.VoiceRoomRegionCountry
err = s.db.WithContext(ctx).
Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true).

View File

@ -176,6 +176,38 @@ func TestDelayedPacketSendsRegionIMBroadcastOnly(t *testing.T) {
}
}
func TestResolveUserRegionPrefersJavaRegionResolver(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
service.SetRegionResolver(fakeRegionResolver{
regions: map[string]string{"LIKEI|SA": "中东区"},
})
fakeRegionIM := &fakeRegionIMGateway{}
service.regionIM = fakeRegionIM
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
presence, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001})
if err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
if presence.RegionCode != "中东区" {
t.Fatalf("presence region = %q, want Java region", presence.RegionCode)
}
_, err = service.Send(ctx, sender, SendRequest{
RequestID: "req-delayed-java-region",
RoomID: 9001,
PacketMode: packetModeDelayed,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(fakeRegionIM.sent) != 1 || fakeRegionIM.sent[0].regionCode != "中东区" {
t.Fatalf("sent region messages = %+v, want one Java-region message", fakeRegionIM.sent)
}
}
func TestImmediatePacketSendsRoomIMMessage(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
fakeIM := &fakeIMGateway{}
@ -410,6 +442,19 @@ func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrig
return "@region-" + strings.ToLower(regionCode), nil
}
type fakeRegionResolver struct {
regions map[string]string
err error
}
func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) {
if g.err != nil {
return "", g.err
}
key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode))
return strings.TrimSpace(g.regions[key]), nil
}
type fakeWalletGateway struct {
balance map[int64]int64
events map[string]struct{}

View File

@ -96,16 +96,21 @@ type roomProfileGateway interface {
MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error)
}
type regionResolver interface {
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
}
// Service 承接语音房红包配置、发送、领取、在线态和退款补偿。
type Service struct {
cfg config.Config
db voiceRoomRedPacketDB
redis redis.Cmdable
wallet walletGateway
im imGateway
regionIM regionIMGateway
roomProfiles roomProfileGateway
notifier *redisNotifier
cfg config.Config
db voiceRoomRedPacketDB
redis redis.Cmdable
wallet walletGateway
im imGateway
regionIM regionIMGateway
roomProfiles roomProfileGateway
regionResolver regionResolver
notifier *redisNotifier
}
func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, wallet walletGateway, regionIM regionIMGateway, roomProfiles ...roomProfileGateway) *Service {
@ -124,6 +129,12 @@ func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable,
return service
}
func (s *Service) SetRegionResolver(resolver regionResolver) {
if s != nil {
s.regionResolver = resolver
}
}
type ConfigResponse struct {
Configured bool `json:"configured"`
ID int64 `json:"id,string"`

View 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赠送记录';

View 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';

View 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='币安自动充值验证记录';