From 0db85586be459dba4904fc5a8dbc221938f677d1 Mon Sep 17 00:00:00 2001 From: hy001 Date: Sat, 18 Apr 2026 18:53:18 +0800 Subject: [PATCH] feat: refactor modules and add lucky gift services --- .gitignore | 2 + cmd/api/main.go | 94 +- cmd/consumer/main.go | 39 + go.mod | 45 +- go.sum | 434 ++++- internal/bootstrap/bootstrap.go | 36 + internal/common/app_error.go | 18 + internal/common/auth_user.go | 9 + internal/config/config.go | 310 +++- internal/config/config_test.go | 101 ++ internal/http/router.go | 204 --- internal/integration/gateways.go | 214 +++ internal/integration/java.go | 108 +- internal/model/baishun_models.go | 262 +-- internal/model/common_models.go | 28 + internal/model/invite_models.go | 150 ++ internal/model/luckygift_models.go | 49 + internal/model/models.go | 177 -- internal/model/registerreward_models.go | 40 + internal/model/weekstar_models.go | 118 ++ internal/repo/repo.go | 35 +- .../baishun_routes.go} | 101 +- internal/router/invite_routes.go | 77 + internal/router/lucky_gift_routes.go | 33 + internal/router/middleware.go | 122 ++ internal/router/register_reward_routes.go | 110 ++ internal/router/response.go | 56 + internal/router/router.go | 63 + internal/router/week_star_routes.go | 108 ++ internal/service/baishun.go | 1434 ---------------- internal/service/baishun/aliases.go | 8 + internal/service/baishun/callback.go | 408 +++++ internal/service/baishun/catalog.go | 131 ++ internal/service/baishun/convert.go | 131 ++ internal/service/baishun/game_row.go | 105 ++ internal/service/baishun/launch.go | 263 +++ internal/service/baishun/room.go | 232 +++ internal/service/baishun/types.go | 319 ++++ internal/service/invite.go | 1527 ----------------- internal/service/invite/bind.go | 314 ++++ internal/service/invite/home.go | 172 ++ internal/service/invite/landing.go | 45 + internal/service/invite/normalize.go | 55 + internal/service/invite/recharge.go | 302 ++++ internal/service/invite/reward.go | 210 +++ internal/service/invite/store.go | 207 +++ internal/service/invite/task.go | 167 ++ internal/service/invite/types.go | 224 +++ internal/service/luckygift/aliases.go | 7 + internal/service/luckygift/draw.go | 280 +++ internal/service/luckygift/provider.go | 335 ++++ internal/service/luckygift/service_test.go | 149 ++ internal/service/luckygift/store.go | 208 +++ internal/service/luckygift/types.go | 104 ++ internal/service/register_reward.go | 458 ----- internal/service/registerreward/admin.go | 254 +++ internal/service/registerreward/consumer.go | 202 +++ internal/service/registerreward/content.go | 108 ++ .../service/registerreward/content_test.go | 44 + internal/service/registerreward/debug.go | 106 ++ internal/service/registerreward/debug_test.go | 33 + internal/service/registerreward/notify.go | 80 + internal/service/registerreward/quota.go | 129 ++ internal/service/registerreward/quota_test.go | 46 + internal/service/registerreward/reward.go | 170 ++ internal/service/registerreward/status.go | 188 ++ .../service/registerreward/status_test.go | 83 + internal/service/registerreward/stream.go | 112 ++ internal/service/registerreward/types.go | 184 ++ internal/service/weekstar/aliases.go | 7 + internal/service/weekstar/config.go | 533 ++++++ internal/service/weekstar/cycle.go | 95 + internal/service/weekstar/event.go | 108 ++ internal/service/weekstar/lifecycle.go | 103 ++ internal/service/weekstar/mapper.go | 232 +++ internal/service/weekstar/page.go | 368 ++++ internal/service/weekstar/retry_task.go | 48 + internal/service/weekstar/types.go | 277 +++ internal/{util => utils}/id.go | 2 +- internal/utils/json.go | 12 + migrations/001_invite_campaign.sql | 4 + migrations/003_week_star_activity.sql | 104 ++ migrations/004_lucky_gift.sql | 43 + .../005_register_reward_daily_limit.sql | 100 ++ ...lucky_gift_java_go_live_smoke.example.json | 92 + scripts/lucky_gift_live_smoke.example.json | 27 + scripts/run_lucky_gift_e2e.sh | 8 + scripts/run_lucky_gift_java_go_live_smoke.sh | 8 + scripts/run_lucky_gift_live_smoke.sh | 8 + 89 files changed, 10707 insertions(+), 4149 deletions(-) create mode 100644 cmd/consumer/main.go create mode 100644 internal/bootstrap/bootstrap.go create mode 100644 internal/common/app_error.go create mode 100644 internal/common/auth_user.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/http/router.go create mode 100644 internal/integration/gateways.go create mode 100644 internal/model/common_models.go create mode 100644 internal/model/invite_models.go create mode 100644 internal/model/luckygift_models.go delete mode 100644 internal/model/models.go create mode 100644 internal/model/registerreward_models.go create mode 100644 internal/model/weekstar_models.go rename internal/{http/baishun_handler.go => router/baishun_routes.go} (74%) create mode 100644 internal/router/invite_routes.go create mode 100644 internal/router/lucky_gift_routes.go create mode 100644 internal/router/middleware.go create mode 100644 internal/router/register_reward_routes.go create mode 100644 internal/router/response.go create mode 100644 internal/router/router.go create mode 100644 internal/router/week_star_routes.go delete mode 100644 internal/service/baishun.go create mode 100644 internal/service/baishun/aliases.go create mode 100644 internal/service/baishun/callback.go create mode 100644 internal/service/baishun/catalog.go create mode 100644 internal/service/baishun/convert.go create mode 100644 internal/service/baishun/game_row.go create mode 100644 internal/service/baishun/launch.go create mode 100644 internal/service/baishun/room.go create mode 100644 internal/service/baishun/types.go delete mode 100644 internal/service/invite.go create mode 100644 internal/service/invite/bind.go create mode 100644 internal/service/invite/home.go create mode 100644 internal/service/invite/landing.go create mode 100644 internal/service/invite/normalize.go create mode 100644 internal/service/invite/recharge.go create mode 100644 internal/service/invite/reward.go create mode 100644 internal/service/invite/store.go create mode 100644 internal/service/invite/task.go create mode 100644 internal/service/invite/types.go create mode 100644 internal/service/luckygift/aliases.go create mode 100644 internal/service/luckygift/draw.go create mode 100644 internal/service/luckygift/provider.go create mode 100644 internal/service/luckygift/service_test.go create mode 100644 internal/service/luckygift/store.go create mode 100644 internal/service/luckygift/types.go delete mode 100644 internal/service/register_reward.go create mode 100644 internal/service/registerreward/admin.go create mode 100644 internal/service/registerreward/consumer.go create mode 100644 internal/service/registerreward/content.go create mode 100644 internal/service/registerreward/content_test.go create mode 100644 internal/service/registerreward/debug.go create mode 100644 internal/service/registerreward/debug_test.go create mode 100644 internal/service/registerreward/notify.go create mode 100644 internal/service/registerreward/quota.go create mode 100644 internal/service/registerreward/quota_test.go create mode 100644 internal/service/registerreward/reward.go create mode 100644 internal/service/registerreward/status.go create mode 100644 internal/service/registerreward/status_test.go create mode 100644 internal/service/registerreward/stream.go create mode 100644 internal/service/registerreward/types.go create mode 100644 internal/service/weekstar/aliases.go create mode 100644 internal/service/weekstar/config.go create mode 100644 internal/service/weekstar/cycle.go create mode 100644 internal/service/weekstar/event.go create mode 100644 internal/service/weekstar/lifecycle.go create mode 100644 internal/service/weekstar/mapper.go create mode 100644 internal/service/weekstar/page.go create mode 100644 internal/service/weekstar/retry_task.go create mode 100644 internal/service/weekstar/types.go rename internal/{util => utils}/id.go (95%) create mode 100644 internal/utils/json.go create mode 100644 migrations/003_week_star_activity.sql create mode 100644 migrations/004_lucky_gift.sql create mode 100644 migrations/005_register_reward_daily_limit.sql create mode 100644 scripts/lucky_gift_java_go_live_smoke.example.json create mode 100644 scripts/lucky_gift_live_smoke.example.json create mode 100755 scripts/run_lucky_gift_e2e.sh create mode 100755 scripts/run_lucky_gift_java_go_live_smoke.sh create mode 100755 scripts/run_lucky_gift_live_smoke.sh diff --git a/.gitignore b/.gitignore index f2564bc..2602b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store target/ +/api +/consumer diff --git a/cmd/api/main.go b/cmd/api/main.go index 562b508..9c37019 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,48 +1,80 @@ package main import ( - "chatapp3-golang/internal/config" - invitehttp "chatapp3-golang/internal/http" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/repo" - "chatapp3-golang/internal/service" + "chatapp3-golang/internal/bootstrap" + "chatapp3-golang/internal/router" + "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/invite" + "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/weekstar" "context" + "errors" "log" + "net/http" + "os/signal" + "syscall" "time" ) func main() { - cfg := config.Load() - repository, err := repo.New(cfg) - if err != nil { - log.Fatalf("init repository failed: %v", err) - } - - if cfg.AutoMigrate { - if err := repository.AutoMigrate(); err != nil { - log.Fatalf("auto migrate failed: %v", err) - } - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := repository.Ping(ctx); err != nil { - log.Fatalf("ping dependencies failed: %v", err) + + app, err := bootstrap.New(ctx) + if err != nil { + log.Fatalf("bootstrap api failed: %v", err) } - javaClient := integration.New(cfg) - inviteService := service.NewInviteService(cfg, repository, javaClient) - baishunService := service.NewBaishunService(cfg, repository, javaClient) - registerRewardService := service.NewRegisterRewardService(cfg, repository, javaClient) - workerCtx, workerCancel := context.WithCancel(context.Background()) - defer workerCancel() - if err := registerRewardService.Start(workerCtx); err != nil { - log.Fatalf("start register reward consumer failed: %v", err) + 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) + luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) + 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) + engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ + Invite: inviteService, + Baishun: baishunService, + LuckyGift: luckyGiftService, + RegisterReward: registerRewardService, + WeekStar: weekStarService, + }) + server := &http.Server{ + Addr: app.Config.HTTP.ListenAddr, + Handler: engine, + ReadHeaderTimeout: 10 * time.Second, } - router := invitehttp.NewRouter(cfg, repository, javaClient, inviteService, baishunService) + serverErrors := make(chan error, 1) + go func() { + log.Printf("chatapp3-golang api listening on %s", app.Config.HTTP.ListenAddr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErrors <- err + return + } + serverErrors <- nil + }() - log.Printf("chatapp3-golang api listening on %s", cfg.ListenAddr) - if err := router.Run(cfg.ListenAddr); err != nil { - log.Fatalf("run server failed: %v", err) + shutdownSignal, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + select { + case err := <-serverErrors: + if err != nil { + log.Fatalf("run server failed: %v", err) + } + return + case <-shutdownSignal.Done(): + log.Printf("shutdown signal received, draining http traffic") + } + + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), app.Config.HTTP.ShutdownTimeout) + defer cancelShutdown() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("graceful shutdown failed: %v", err) + if closeErr := server.Close(); closeErr != nil { + log.Printf("force close failed: %v", closeErr) + } + } + if err := <-serverErrors; err != nil { + log.Fatalf("server exited with error during shutdown: %v", err) } } diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go new file mode 100644 index 0000000..31ec32b --- /dev/null +++ b/cmd/consumer/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "chatapp3-golang/internal/bootstrap" + "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/weekstar" + "context" + "log" + "os/signal" + "syscall" + "time" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + app, err := bootstrap.New(ctx) + if err != nil { + log.Fatalf("bootstrap consumer failed: %v", err) + } + + 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) + + workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer workerCancel() + + if err := registerRewardService.Start(workerCtx); err != nil { + log.Fatalf("start register reward consumer failed: %v", err) + } + if err := weekStarService.StartMessageConsumer(workerCtx); err != nil { + log.Fatalf("start week star message consumer failed: %v", err) + } + + log.Printf("chatapp3-golang consumer started") + <-workerCtx.Done() + log.Printf("chatapp3-golang consumer stopped") +} diff --git a/go.mod b/go.mod index 3db11a5..8161cc7 100644 --- a/go.mod +++ b/go.mod @@ -3,43 +3,70 @@ module chatapp3-golang go 1.24 require ( + github.com/apache/rocketmq-clients/golang/v5 v5.1.3 github.com/bwmarrin/snowflake v0.3.0 github.com/gin-gonic/gin v1.10.0 github.com/redis/go-redis/v9 v9.7.0 gorm.io/driver/mysql v1.5.7 - gorm.io/gorm v1.25.12 + gorm.io/gorm v1.30.0 ) require ( + contrib.go.opencensus.io/exporter/ocagent v0.7.0 // indirect + github.com/alicebob/miniredis/v2 v2.37.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // 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 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + 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/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/valyala/fastrand v1.1.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.opencensus.io v0.24.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/api v0.230.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect + google.golang.org/grpc v1.72.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 9fa1519..6f1a539 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,36 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +contrib.go.opencensus.io/exporter/ocagent v0.7.0 h1:BEfdCTXfMV30tLZD8c9n64V/tIZX5+9sXiuFLnrr1k8= +contrib.go.opencensus.io/exporter/ocagent v0.7.0/go.mod h1:IshRmMJBhDfFj5Y67nVhMYTTIze91RUeT73ipWKs/GY= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/rocketmq-clients/golang/v5 v5.1.3 h1:ooj+E/fX6oSKEABCHdMglxcQvFIde5VSwdwnP2Zph7s= +github.com/apache/rocketmq-clients/golang/v5 v5.1.3/go.mod h1:qg/POLGOcuU33gPbi2yA6Ak4kTPydBBamrQU+bl0WMU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -8,68 +41,172 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +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= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= 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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +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= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/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/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= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +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/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= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -81,32 +218,289 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.25.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= +google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f h1:tjZsroqekhC63+WMqzmWyW5Twj/ZfR5HAlpd5YQ1Vs0= +google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..f84f26a --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,36 @@ +package bootstrap + +import ( + "context" + "fmt" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/repo" +) + +// App 聚合进程启动所需的基础依赖,避免 api 和 consumer 重复拼装。 +type App struct { + Config config.Config + Repository *repo.Repository + Gateways integration.Gateways +} + +// New 完成配置加载、存储初始化和下游网关装配。 +func New(ctx context.Context) (*App, error) { + cfg := config.Load() + + repository, err := repo.New(cfg) + if err != nil { + return nil, fmt.Errorf("init repository: %w", err) + } + if err := repository.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping dependencies: %w", err) + } + + return &App{ + Config: cfg, + Repository: repository, + Gateways: integration.NewGateways(cfg), + }, nil +} diff --git a/internal/common/app_error.go b/internal/common/app_error.go new file mode 100644 index 0000000..f7facba --- /dev/null +++ b/internal/common/app_error.go @@ -0,0 +1,18 @@ +package common + +// AppError 是统一的业务错误结构,供服务层和路由层传递 HTTP 语义。 +type AppError struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +// Error 返回错误消息文本。 +func (e *AppError) Error() string { + return e.Message +} + +// NewAppError 创建一个带业务码的应用错误。 +func NewAppError(status int, code, message string) *AppError { + return &AppError{Status: status, Code: code, Message: message} +} diff --git a/internal/common/auth_user.go b/internal/common/auth_user.go new file mode 100644 index 0000000..e015fe5 --- /dev/null +++ b/internal/common/auth_user.go @@ -0,0 +1,9 @@ +package common + +// AuthUser 表示已经完成鉴权的调用方用户。 +type AuthUser struct { + UserID int64 + SysOrigin string + Token string + Authorization string +} diff --git a/internal/config/config.go b/internal/config/config.go index 6fa0e36..8e8e84b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,87 +1,205 @@ package config import ( + "encoding/json" "os" "strconv" "strings" "time" ) +// Config 是按运行层和业务模块分段后的应用配置。 type Config struct { - ListenAddr string - PublicBaseURL string - MySQLDSN string - RedisAddr string - RedisPassword string - RedisDB int - AutoMigrate bool - JavaAuthBaseURL string - JavaDeviceBaseURL string - JavaAppBaseURL string - JavaBridgeBaseURL string - JavaOtherBaseURL string - JavaWalletBaseURL string - BaishunPlatformBaseURL string - BaishunAppID int64 - BaishunAppName string - BaishunAppChannel string - BaishunAppKey string - BaishunGSP int - BaishunLaunchCodeTTLSeconds int - BaishunSSTokenTTLSeconds int - InternalCallbackSecret string - RegisterRewardStreamKey string - RegisterRewardConsumerGroup string - RegisterRewardConsumerName string - RegisterRewardBatchSize int64 - RegisterRewardBlock time.Duration - RegisterRewardMinIdle time.Duration - Timeout time.Duration + HTTP HTTPConfig + Store StoreConfig + Java JavaConfig + Worker WorkerConfig + Invite InviteConfig + WeekStar WeekStarConfig + Baishun BaishunConfig + LuckyGift LuckyGiftConfig +} + +// HTTPConfig 保存 HTTP 服务运行配置。 +type HTTPConfig struct { + ListenAddr string + Timeout time.Duration + ShutdownTimeout time.Duration + InternalCallbackSecret string +} + +// StoreConfig 保存 MySQL 和 Redis 连接配置。 +type StoreConfig struct { + MySQLDSN string + RedisAddr string + RedisPassword string + RedisDB int +} + +// JavaConfig 保存各类 Java 下游服务地址。 +type JavaConfig struct { + AuthBaseURL string + ConsoleBaseURL string + DeviceBaseURL string + AppBaseURL string + BridgeBaseURL string + ExternalBaseURL string + OtherBaseURL string + WalletBaseURL string +} + +// WorkerConfig 保存事件消费类 worker 的运行配置。 +type WorkerConfig struct { + RegisterReward RegisterRewardWorkerConfig +} + +// RegisterRewardWorkerConfig 保存注册奖励消费配置。 +type RegisterRewardWorkerConfig struct { + StreamKey string + ConsumerGroup string + ConsumerName string + BatchSize int64 + Block time.Duration + MinIdle time.Duration +} + +// InviteConfig 保存邀请活动相关配置。 +type InviteConfig struct { + PublicBaseURL string +} + +// WeekStarConfig 保存周榜模块配置。 +type WeekStarConfig struct { + DefaultSysOrigin string + Timezone string + RetryStreamKey string + RocketMQ WeekStarRocketMQConfig +} + +// WeekStarRocketMQConfig 保存周榜 MQ 消费配置。 +type WeekStarRocketMQConfig struct { + Enabled bool + Endpoint string + Namespace string + AccessKey string + AccessSecret string + SecurityToken string + ConsumerGroup string + Topic string + Tag string +} + +// BaishunConfig 保存百顺模块配置。 +type BaishunConfig struct { + PlatformBaseURL string + AppID int64 + AppName string + AppChannel string + AppKey string + GSP int + LaunchCodeTTLSeconds int + SSTokenTTLSeconds int +} + +// LuckyGiftConfig 保存幸运礼物模块配置。 +type LuckyGiftConfig struct { + Enabled bool + Timeout time.Duration + Providers []LuckyGiftProviderConfig +} + +type LuckyGiftProviderConfig struct { + StandardID int64 `json:"standardId"` + URL string `json:"url"` + AppID string `json:"appId"` + AppChannel string `json:"appChannel"` + AppKey string `json:"appKey"` } func Load() Config { + javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400") + return Config{ - ListenAddr: getEnv("INVITE_HTTP_ADDR", ":2900"), - PublicBaseURL: getEnv("INVITE_PUBLIC_BASE_URL", "http://localhost:2900"), - MySQLDSN: getEnv("INVITE_MYSQL_DSN", "root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local"), - RedisAddr: getEnv("INVITE_REDIS_ADDR", "127.0.0.1:6379"), - RedisPassword: getEnv("INVITE_REDIS_PASSWORD", ""), - RedisDB: getEnvInt("INVITE_REDIS_DB", 0), - AutoMigrate: getEnvBool("INVITE_AUTO_MIGRATE", false), - JavaAuthBaseURL: getEnv("INVITE_JAVA_AUTH_BASE_URL", "http://127.0.0.1:2100"), - JavaDeviceBaseURL: getEnv("INVITE_JAVA_DEVICE_BASE_URL", "http://127.0.0.1:2400"), - JavaAppBaseURL: getEnv("INVITE_JAVA_APP_BASE_URL", "http://127.0.0.1:2400"), - JavaBridgeBaseURL: getEnv("INVITE_JAVA_BRIDGE_BASE_URL", "http://127.0.0.1:2200"), - JavaOtherBaseURL: getEnv("GAME_JAVA_OTHER_BASE_URL", getEnv("INVITE_JAVA_APP_BASE_URL", "http://127.0.0.1:2400")), - JavaWalletBaseURL: getEnv("GAME_JAVA_WALLET_BASE_URL", "http://127.0.0.1:2300"), - BaishunPlatformBaseURL: getEnv("BAISHUN_PLATFORM_BASE_URL", ""), - BaishunAppID: getEnvInt64("BAISHUN_APP_ID", 0), - BaishunAppName: getEnv("BAISHUN_APP_NAME", ""), - BaishunAppChannel: getEnv("BAISHUN_APP_CHANNEL", ""), - BaishunAppKey: getEnv("BAISHUN_APP_KEY", ""), - BaishunGSP: getEnvInt("BAISHUN_GSP", 101), - BaishunLaunchCodeTTLSeconds: getEnvInt("BAISHUN_LAUNCH_CODE_TTL_SECONDS", 300), - BaishunSSTokenTTLSeconds: getEnvInt("BAISHUN_SS_TOKEN_TTL_SECONDS", 86400), - InternalCallbackSecret: getEnv("GAME_INTERNAL_CALLBACK_SECRET", ""), - RegisterRewardStreamKey: getEnv("REGISTER_REWARD_STREAM_KEY", "activity:register:reward"), - RegisterRewardConsumerGroup: getEnv("REGISTER_REWARD_CONSUMER_GROUP", "register-reward"), - RegisterRewardConsumerName: getEnv("REGISTER_REWARD_CONSUMER_NAME", defaultConsumerName("register-reward")), - RegisterRewardBatchSize: int64(getEnvInt("REGISTER_REWARD_BATCH_SIZE", 10)), - RegisterRewardBlock: time.Duration(getEnvInt("REGISTER_REWARD_BLOCK_MILLIS", 5000)) * time.Millisecond, - RegisterRewardMinIdle: time.Duration(getEnvInt("REGISTER_REWARD_MIN_IDLE_MILLIS", 60000)) * time.Millisecond, - Timeout: time.Duration(getEnvInt("INVITE_HTTP_TIMEOUT_SECONDS", 8)) * time.Second, + HTTP: HTTPConfig{ + ListenAddr: getEnvAny([]string{"CHATAPP_HTTP_LISTEN_ADDR", "INVITE_HTTP_ADDR"}, ":2900"), + Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_HTTP_TIMEOUT_SECONDS", "INVITE_HTTP_TIMEOUT_SECONDS"}, 8)) * time.Second, + ShutdownTimeout: time.Duration(getEnvIntAny([]string{"CHATAPP_HTTP_SHUTDOWN_TIMEOUT_SECONDS", "INVITE_HTTP_SHUTDOWN_TIMEOUT_SECONDS"}, 30)) * time.Second, + InternalCallbackSecret: getEnvAny([]string{"CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET", "GAME_INTERNAL_CALLBACK_SECRET"}, ""), + }, + Store: StoreConfig{ + MySQLDSN: getEnvAny([]string{"CHATAPP_STORE_MYSQL_DSN", "INVITE_MYSQL_DSN"}, "root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local"), + RedisAddr: getEnvAny([]string{"CHATAPP_STORE_REDIS_ADDR", "INVITE_REDIS_ADDR"}, "127.0.0.1:6379"), + RedisPassword: getEnvAny([]string{"CHATAPP_STORE_REDIS_PASSWORD", "INVITE_REDIS_PASSWORD"}, ""), + RedisDB: getEnvIntAny([]string{"CHATAPP_STORE_REDIS_DB", "INVITE_REDIS_DB"}, 0), + }, + Java: JavaConfig{ + AuthBaseURL: getEnvAny([]string{"CHATAPP_JAVA_AUTH_BASE_URL", "INVITE_JAVA_AUTH_BASE_URL"}, "http://127.0.0.1:2100"), + ConsoleBaseURL: getEnvAny([]string{"CHATAPP_JAVA_CONSOLE_BASE_URL", "JAVA_CONSOLE_BASE_URL"}, "http://127.0.0.1:2700/console"), + DeviceBaseURL: getEnvAny([]string{"CHATAPP_JAVA_DEVICE_BASE_URL", "INVITE_JAVA_DEVICE_BASE_URL"}, "http://127.0.0.1:2400"), + AppBaseURL: javaAppBaseURL, + BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, "http://127.0.0.1:2200"), + 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), + WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"), + }, + Worker: WorkerConfig{ + RegisterReward: RegisterRewardWorkerConfig{ + StreamKey: getEnvAny([]string{"CHATAPP_REGISTER_REWARD_STREAM_KEY", "REGISTER_REWARD_STREAM_KEY"}, "activity:register:reward"), + ConsumerGroup: getEnvAny([]string{"CHATAPP_REGISTER_REWARD_CONSUMER_GROUP", "REGISTER_REWARD_CONSUMER_GROUP"}, "register-reward"), + ConsumerName: getEnvAny([]string{"CHATAPP_REGISTER_REWARD_CONSUMER_NAME", "REGISTER_REWARD_CONSUMER_NAME"}, defaultConsumerName("register-reward")), + BatchSize: int64(getEnvIntAny([]string{"CHATAPP_REGISTER_REWARD_BATCH_SIZE", "REGISTER_REWARD_BATCH_SIZE"}, 10)), + Block: time.Duration(getEnvIntAny([]string{"CHATAPP_REGISTER_REWARD_BLOCK_MILLIS", "REGISTER_REWARD_BLOCK_MILLIS"}, 5000)) * time.Millisecond, + MinIdle: time.Duration(getEnvIntAny([]string{"CHATAPP_REGISTER_REWARD_MIN_IDLE_MILLIS", "REGISTER_REWARD_MIN_IDLE_MILLIS"}, 60000)) * time.Millisecond, + }, + }, + Invite: InviteConfig{ + PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"), + }, + WeekStar: WeekStarConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")), + Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"), + RetryStreamKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "WEEK_STAR_RETRY_STREAM_KEY"}, "week-star:reward-retry"), + RocketMQ: WeekStarRocketMQConfig{ + Enabled: getEnvBoolAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED", "WEEK_STAR_ROCKETMQ_ENABLED"}, false), + Endpoint: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT", "WEEK_STAR_ROCKETMQ_ENDPOINT"}, ""), + Namespace: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE", "WEEK_STAR_ROCKETMQ_NAMESPACE"}, ""), + AccessKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY", "WEEK_STAR_ROCKETMQ_ACCESS_KEY"}, ""), + AccessSecret: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET", "WEEK_STAR_ROCKETMQ_ACCESS_SECRET"}, ""), + SecurityToken: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN", "WEEK_STAR_ROCKETMQ_SECURITY_TOKEN"}, ""), + ConsumerGroup: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_CONSUMER_GROUP", "WEEK_STAR_ROCKETMQ_CONSUMER_GROUP"}, "week-star-activity"), + Topic: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC", "WEEK_STAR_ROCKETMQ_TOPIC"}, "GIVE_GIFTS"), + Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"), + }, + }, + Baishun: BaishunConfig{ + PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""), + AppID: getEnvInt64Any([]string{"CHATAPP_BAISHUN_APP_ID", "BAISHUN_APP_ID"}, 0), + AppName: getEnvAny([]string{"CHATAPP_BAISHUN_APP_NAME", "BAISHUN_APP_NAME"}, ""), + AppChannel: getEnvAny([]string{"CHATAPP_BAISHUN_APP_CHANNEL", "BAISHUN_APP_CHANNEL"}, ""), + AppKey: getEnvAny([]string{"CHATAPP_BAISHUN_APP_KEY", "BAISHUN_APP_KEY"}, ""), + GSP: getEnvIntAny([]string{"CHATAPP_BAISHUN_GSP", "BAISHUN_GSP"}, 101), + LaunchCodeTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS", "BAISHUN_LAUNCH_CODE_TTL_SECONDS"}, 300), + SSTokenTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS", "BAISHUN_SS_TOKEN_TTL_SECONDS"}, 86400), + }, + LuckyGift: LuckyGiftConfig{ + Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false), + Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond, + Providers: loadLuckyGiftConfigs(), + }, } } -func getEnv(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value +func getEnvAny(keys []string, fallback string) string { + for _, key := range keys { + if value := os.Getenv(strings.TrimSpace(key)); value != "" { + return value + } } return fallback } -func getEnvInt(key string, fallback int) int { - value := os.Getenv(key) +func getEnvIntAny(keys []string, fallback int) int { + value := getEnvAny(keys, "") if value == "" { return fallback } @@ -92,8 +210,8 @@ func getEnvInt(key string, fallback int) int { return parsed } -func getEnvInt64(key string, fallback int64) int64 { - value := os.Getenv(key) +func getEnvInt64Any(keys []string, fallback int64) int64 { + value := getEnvAny(keys, "") if value == "" { return fallback } @@ -104,8 +222,8 @@ func getEnvInt64(key string, fallback int64) int64 { return parsed } -func getEnvBool(key string, fallback bool) bool { - value := os.Getenv(key) +func getEnvBoolAny(keys []string, fallback bool) bool { + value := getEnvAny(keys, "") if value == "" { return fallback } @@ -127,3 +245,61 @@ func defaultConsumerName(prefix string) string { } return prefix + "-" + host } + +func loadLuckyGiftConfigs() []LuckyGiftProviderConfig { + raw := strings.TrimSpace(getEnvAny([]string{"CHATAPP_LUCKY_GIFT_CONFIGS_JSON", "LUCKY_GIFT_CONFIGS_JSON", "LUCKY_GIFT_CONFIGS"}, "")) + sharedBaishunAppID := strings.TrimSpace(getEnvAny([]string{"CHATAPP_BAISHUN_APP_ID", "BAISHUN_APP_ID"}, "")) + sharedBaishunAppChannel := strings.TrimSpace(getEnvAny([]string{"CHATAPP_BAISHUN_APP_CHANNEL", "BAISHUN_APP_CHANNEL"}, "")) + sharedBaishunAppKey := strings.TrimSpace(getEnvAny([]string{"CHATAPP_BAISHUN_APP_KEY", "BAISHUN_APP_KEY"}, "")) + if raw != "" { + var list []LuckyGiftProviderConfig + if err := json.Unmarshal([]byte(raw), &list); err == nil { + return normalizeLuckyGiftConfigs(list, sharedBaishunAppID, sharedBaishunAppChannel, sharedBaishunAppKey) + } + var single LuckyGiftProviderConfig + if err := json.Unmarshal([]byte(raw), &single); err == nil { + return normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{single}, sharedBaishunAppID, sharedBaishunAppChannel, sharedBaishunAppKey) + } + } + + single := LuckyGiftProviderConfig{ + StandardID: getEnvInt64Any([]string{"CHATAPP_LUCKY_GIFT_STANDARD_ID", "LUCKY_GIFT_STANDARD_ID"}, 0), + URL: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_URL", "LUCKY_GIFT_URL", "LUCKY_GIFT_API_DEFAULT_URL"}, ""), + AppID: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_APP_ID", "LUCKY_GIFT_APP_ID", "LUCKY_GIFT_API_DEFAULT_APP_ID"}, sharedBaishunAppID), + AppChannel: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_APP_CHANNEL", "LUCKY_GIFT_APP_CHANNEL", "LUCKY_GIFT_API_DEFAULT_APP_CHANNEL"}, sharedBaishunAppChannel), + AppKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_APP_KEY", "LUCKY_GIFT_APP_KEY", "LUCKY_GIFT_API_DEFAULT_APP_KEY"}, sharedBaishunAppKey), + } + return normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{single}, sharedBaishunAppID, sharedBaishunAppChannel, sharedBaishunAppKey) +} + +func normalizeLuckyGiftConfigs( + list []LuckyGiftProviderConfig, + sharedBaishunAppID string, + sharedBaishunAppChannel string, + sharedBaishunAppKey string, +) []LuckyGiftProviderConfig { + result := make([]LuckyGiftProviderConfig, 0, len(list)) + sharedBaishunAppID = strings.TrimSpace(sharedBaishunAppID) + sharedBaishunAppChannel = strings.TrimSpace(sharedBaishunAppChannel) + sharedBaishunAppKey = strings.TrimSpace(sharedBaishunAppKey) + for _, item := range list { + item.URL = strings.TrimSpace(item.URL) + item.AppID = strings.TrimSpace(item.AppID) + item.AppChannel = strings.TrimSpace(item.AppChannel) + item.AppKey = strings.TrimSpace(item.AppKey) + if sharedBaishunAppID != "" { + item.AppID = sharedBaishunAppID + } + if sharedBaishunAppChannel != "" { + item.AppChannel = sharedBaishunAppChannel + } + if sharedBaishunAppKey != "" { + item.AppKey = sharedBaishunAppKey + } + if item.URL == "" || item.AppID == "" || item.AppChannel == "" || item.AppKey == "" { + continue + } + result = append(result, item) + } + return result +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e4d7781 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "os" + "testing" +) + +func TestNormalizeLuckyGiftConfigsUsesSharedBaishunAppKey(t *testing.T) { + configs := normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{ + { + StandardID: 18816, + URL: "https://provider.example.com", + AppID: "lucky-app-id", + AppChannel: "lucky-app-channel", + AppKey: "legacy-lucky-key", + }, + }, "shared-baishun-app-id", "shared-baishun-app-channel", "shared-baishun-key") + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + if got := configs[0].AppID; got != "shared-baishun-app-id" { + t.Fatalf("expected baishun app id override, got %q", got) + } + if got := configs[0].AppChannel; got != "shared-baishun-app-channel" { + t.Fatalf("expected baishun app channel override, got %q", got) + } + if got := configs[0].AppKey; got != "shared-baishun-key" { + t.Fatalf("expected baishun key override, got %q", got) + } +} + +func TestNormalizeLuckyGiftConfigsKeepsDedicatedKeyWhenSharedBlank(t *testing.T) { + configs := normalizeLuckyGiftConfigs([]LuckyGiftProviderConfig{ + { + StandardID: 18816, + URL: "https://provider.example.com", + AppID: "lucky-app-id", + AppChannel: "lucky-app-channel", + AppKey: "legacy-lucky-key", + }, + }, "", "", "") + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + if got := configs[0].AppID; got != "lucky-app-id" { + t.Fatalf("expected dedicated lucky gift app id, got %q", got) + } + if got := configs[0].AppChannel; got != "lucky-app-channel" { + t.Fatalf("expected dedicated lucky gift app channel, got %q", got) + } + if got := configs[0].AppKey; got != "legacy-lucky-key" { + t.Fatalf("expected dedicated lucky gift key, got %q", got) + } +} + +func TestGetEnvBoolAliasSupportsLegacyLuckyGiftKey(t *testing.T) { + t.Setenv("LUCKY_GIFT_GO_ENABLED", "true") + if got := getEnvBoolAny([]string{"LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false); !got { + t.Fatalf("expected legacy lucky gift bool alias to be true") + } +} + +func TestLoadLuckyGiftConfigsSupportsLegacyDefaultEnv(t *testing.T) { + oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON") + oldList, hadList := os.LookupEnv("LUCKY_GIFT_CONFIGS") + t.Cleanup(func() { + if hadJSON { + _ = os.Setenv("LUCKY_GIFT_CONFIGS_JSON", oldJSON) + } else { + _ = os.Unsetenv("LUCKY_GIFT_CONFIGS_JSON") + } + if hadList { + _ = os.Setenv("LUCKY_GIFT_CONFIGS", oldList) + } else { + _ = os.Unsetenv("LUCKY_GIFT_CONFIGS") + } + }) + _ = os.Unsetenv("LUCKY_GIFT_CONFIGS_JSON") + _ = os.Unsetenv("LUCKY_GIFT_CONFIGS") + t.Setenv("LUCKY_GIFT_API_DEFAULT_URL", "https://provider.example.com/lucky_gift/start_game") + t.Setenv("LUCKY_GIFT_API_DEFAULT_APP_ID", "legacy-app-id") + t.Setenv("LUCKY_GIFT_API_DEFAULT_APP_CHANNEL", "legacy-app-channel") + t.Setenv("LUCKY_GIFT_API_DEFAULT_APP_KEY", "legacy-app-key") + + configs := loadLuckyGiftConfigs() + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + if got := configs[0].URL; got != "https://provider.example.com/lucky_gift/start_game" { + t.Fatalf("expected legacy lucky gift default url, got %q", got) + } + if got := configs[0].AppID; got != "legacy-app-id" { + t.Fatalf("expected legacy lucky gift default app id, got %q", got) + } + if got := configs[0].AppChannel; got != "legacy-app-channel" { + t.Fatalf("expected legacy lucky gift default app channel, got %q", got) + } + if got := configs[0].AppKey; got != "legacy-app-key" { + t.Fatalf("expected legacy lucky gift default app key, got %q", got) + } +} diff --git a/internal/http/router.go b/internal/http/router.go deleted file mode 100644 index fc348b8..0000000 --- a/internal/http/router.go +++ /dev/null @@ -1,204 +0,0 @@ -package http - -import ( - "chatapp3-golang/internal/config" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/repo" - "chatapp3-golang/internal/service" - "context" - "errors" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" -) - -const authUserContextKey = "auth_user" - -func NewRouter( - cfg config.Config, - repository *repo.Repository, - javaClient *integration.Client, - inviteService *service.InviteService, - baishunService *service.BaishunService, -) *gin.Engine { - router := gin.New() - router.Use(gin.Logger(), gin.Recovery()) - - router.GET("/health", func(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout) - defer cancel() - if err := repository.Ping(ctx); err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "code": "dependency_unavailable", - "message": err.Error(), - }) - return - } - c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success2"}) - }) - - router.GET("/public/h5/invite-campaign/landing", func(c *gin.Context) { - resp, err := inviteService.GetLanding(c.Request.Context(), c.Query("code")) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - - internal := router.Group("/internal/invite-campaign") - internal.POST("/recharge-success", func(c *gin.Context) { - var req service.RechargeSuccessRequest - if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) - return - } - resp, err := inviteService.HandleRechargeSuccess(c.Request.Context(), req) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - - authenticated := router.Group("/app/h5/invite-campaign") - authenticated.Use(authMiddleware(javaClient)) - authenticated.GET("/home", func(c *gin.Context) { - user := mustAuthUser(c) - resp, err := inviteService.GetHome(c.Request.Context(), user) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - authenticated.POST("/bind-code", func(c *gin.Context) { - user := mustAuthUser(c) - var req service.BindCodeRequest - if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) - return - } - resp, err := inviteService.BindCode(c.Request.Context(), user, resolveClientIP(c), req) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - authenticated.POST("/tasks/claim", func(c *gin.Context) { - user := mustAuthUser(c) - var req service.TaskClaimRequest - if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) - return - } - resp, err := inviteService.ClaimTask(c.Request.Context(), user, req) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - - registerBaishunRoutes(router, cfg, javaClient, baishunService) - - return router -} - -func authMiddleware(javaClient *integration.Client) gin.HandlerFunc { - return func(c *gin.Context) { - authorization := strings.TrimSpace(c.GetHeader("Authorization")) - if authorization == "" { - writeError(c, service.NewAppError(http.StatusUnauthorized, "missing_authorization", "authorization is required")) - c.Abort() - return - } - token := trimBearer(authorization) - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() - credential, err := javaClient.AuthenticateToken(ctx, token) - if err != nil { - writeError(c, service.NewAppError(http.StatusUnauthorized, "invalid_token", err.Error())) - c.Abort() - return - } - c.Set(authUserContextKey, service.AuthUser{ - UserID: int64(credential.UserID), - SysOrigin: credential.SysOrigin, - Token: token, - Authorization: authorization, - }) - c.Next() - } -} - -func mustAuthUser(c *gin.Context) service.AuthUser { - value, exists := c.Get(authUserContextKey) - if !exists { - return service.AuthUser{} - } - user, _ := value.(service.AuthUser) - return user -} - -func writeOK(c *gin.Context, data any) { - c.JSON(http.StatusOK, gin.H{ - "status": true, - "errorCode": 0, - "errorMsg": "success", - "body": data, - "code": "success", - "message": "success", - "data": data, - "time": time.Now().UnixMilli(), - }) -} - -func writeError(c *gin.Context, err error) { - var appErr *service.AppError - if errors.As(err, &appErr) { - c.JSON(appErr.Status, gin.H{ - "status": false, - "errorCode": appErr.Status, - "errorCodeName": appErr.Code, - "errorMsg": appErr.Message, - "body": nil, - "code": appErr.Code, - "message": appErr.Message, - "data": nil, - "time": time.Now().UnixMilli(), - }) - return - } - c.JSON(http.StatusInternalServerError, gin.H{ - "status": false, - "errorCode": http.StatusInternalServerError, - "errorCodeName": "internal_error", - "errorMsg": err.Error(), - "body": nil, - "code": "internal_error", - "message": err.Error(), - "data": nil, - "time": time.Now().UnixMilli(), - }) -} - -func trimBearer(authorization string) string { - if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { - return strings.TrimSpace(authorization[7:]) - } - return authorization -} - -func resolveClientIP(c *gin.Context) string { - if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - return strings.TrimSpace(parts[0]) - } - } - return strings.TrimSpace(c.ClientIP()) -} diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go new file mode 100644 index 0000000..56f4e3a --- /dev/null +++ b/internal/integration/gateways.go @@ -0,0 +1,214 @@ +package integration + +import ( + "context" + + "chatapp3-golang/internal/config" +) + +// Gateways 聚合按职责拆分后的 Java 接口网关。 +type Gateways struct { + Auth *AuthGateway + Device *DeviceGateway + InviteCode *InviteCodeGateway + Notice *NoticeGateway + RewardDispatch *RewardDispatchGateway + RewardQuery *RewardQueryGateway + Profile *ProfileGateway + Wallet *WalletGateway +} + +// NewGateways 基于共享 HTTP 客户端创建所有 Java 网关。 +func NewGateways(cfg config.Config) Gateways { + client := New(cfg) + return Gateways{ + Auth: &AuthGateway{client: client}, + Device: &DeviceGateway{client: client}, + InviteCode: &InviteCodeGateway{client: client}, + Notice: &NoticeGateway{client: client}, + RewardDispatch: &RewardDispatchGateway{client: client}, + RewardQuery: &RewardQueryGateway{client: client}, + Profile: &ProfileGateway{client: client}, + Wallet: &WalletGateway{client: client}, + } +} + +// AuthenticateToken 透传到认证网关。 +func (g *Gateways) AuthenticateToken(ctx context.Context, token string) (UserCredential, error) { + return g.Auth.AuthenticateToken(ctx, token) +} + +// AuthenticateConsoleToken 透传到后台认证网关。 +func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { + return g.Auth.AuthenticateConsoleToken(ctx, authorization) +} + +// GetDeviceFingerprint 透传到设备网关。 +func (g *Gateways) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { + return g.Device.GetDeviceFingerprint(ctx, userID) +} + +// EnsureInviteCode 透传到邀请码网关。 +func (g *Gateways) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { + return g.InviteCode.EnsureInviteCode(ctx, authorization) +} + +// SendOfficialNoticeCustomize 透传到官方通知网关。 +func (g *Gateways) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNoticeCustomizeRequest) error { + return g.Notice.SendOfficialNoticeCustomize(ctx, req) +} + +// GrantGold 透传到奖励发放网关。 +func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error { + return g.RewardDispatch.GrantGold(ctx, req) +} + +// GrantProps 透传到奖励发放网关。 +func (g *Gateways) GrantProps(ctx context.Context, req GrantPropsRequest) error { + return g.RewardDispatch.GrantProps(ctx, req) +} + +// SendActivityReward 透传到奖励发放网关。 +func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { + return g.RewardDispatch.SendActivityReward(ctx, req) +} + +// GetRewardGroupDetail 透传到奖励查询网关。 +func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { + return g.RewardQuery.GetRewardGroupDetail(ctx, groupID) +} + +// GetUserProfile 透传到资料网关。 +func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { + return g.Profile.GetUserProfile(ctx, userID) +} + +// GetUserLanguage 透传到资料网关。 +func (g *Gateways) GetUserLanguage(ctx context.Context, userID int64) (string, error) { + return g.Profile.GetUserLanguage(ctx, userID) +} + +// MapGoldBalance 透传到钱包网关。 +func (g *Gateways) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + return g.Wallet.MapGoldBalance(ctx, userIDs) +} + +// ExistsGoldEvent 透传到钱包网关。 +func (g *Gateways) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { + return g.Wallet.ExistsGoldEvent(ctx, eventID) +} + +// ChangeGoldBalance 透传到钱包网关。 +func (g *Gateways) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { + return g.Wallet.ChangeGoldBalance(ctx, cmd) +} + +// AuthGateway 负责认证相关接口。 +type AuthGateway struct { + client *Client +} + +// AuthenticateToken 校验用户 token。 +func (g *AuthGateway) AuthenticateToken(ctx context.Context, token string) (UserCredential, error) { + return g.client.AuthenticateToken(ctx, token) +} + +// AuthenticateConsoleToken 校验后台 console token。 +func (g *AuthGateway) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { + return g.client.AuthenticateConsoleToken(ctx, authorization) +} + +// DeviceGateway 负责设备指纹相关接口。 +type DeviceGateway struct { + client *Client +} + +// GetDeviceFingerprint 查询设备指纹。 +func (g *DeviceGateway) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { + return g.client.GetDeviceFingerprint(ctx, userID) +} + +// InviteCodeGateway 负责邀请码相关接口。 +type InviteCodeGateway struct { + client *Client +} + +// EnsureInviteCode 确保用户存在邀请码。 +func (g *InviteCodeGateway) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { + return g.client.EnsureInviteCode(ctx, authorization) +} + +// NoticeGateway 负责实时官方通知接口。 +type NoticeGateway struct { + client *Client +} + +// SendOfficialNoticeCustomize 发送自定义官方通知。 +func (g *NoticeGateway) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNoticeCustomizeRequest) error { + return g.client.SendOfficialNoticeCustomize(ctx, req) +} + +// RewardDispatchGateway 负责活动奖励发放接口。 +type RewardDispatchGateway struct { + client *Client +} + +// GrantGold 发放金币奖励。 +func (g *RewardDispatchGateway) GrantGold(ctx context.Context, req GrantGoldRequest) error { + return g.client.GrantGold(ctx, req) +} + +// GrantProps 发放道具奖励。 +func (g *RewardDispatchGateway) GrantProps(ctx context.Context, req GrantPropsRequest) error { + return g.client.GrantProps(ctx, req) +} + +// SendActivityReward 发放活动奖励组。 +func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { + return g.client.SendActivityReward(ctx, req) +} + +// RewardQueryGateway 负责奖励配置查询接口。 +type RewardQueryGateway struct { + client *Client +} + +// GetRewardGroupDetail 查询奖励组详情。 +func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { + return g.client.GetRewardGroupDetail(ctx, groupID) +} + +// ProfileGateway 负责用户资料相关接口。 +type ProfileGateway struct { + client *Client +} + +// GetUserProfile 查询用户资料。 +func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { + return g.client.GetUserProfile(ctx, userID) +} + +// GetUserLanguage 查询用户语言。 +func (g *ProfileGateway) GetUserLanguage(ctx context.Context, userID int64) (string, error) { + return g.client.GetUserLanguage(ctx, userID) +} + +// WalletGateway 负责钱包相关接口。 +type WalletGateway struct { + client *Client +} + +// MapGoldBalance 批量查询金币余额。 +func (g *WalletGateway) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + return g.client.MapGoldBalance(ctx, userIDs) +} + +// ExistsGoldEvent 查询事件幂等是否存在。 +func (g *WalletGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { + return g.client.ExistsGoldEvent(ctx, eventID) +} + +// ChangeGoldBalance 修改金币余额。 +func (g *WalletGateway) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { + return g.client.ChangeGoldBalance(ctx, cmd) +} diff --git a/internal/integration/java.go b/internal/integration/java.go index e0f2c93..f079722 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -29,6 +29,13 @@ type UserCredential struct { Sign string `json:"sign"` } +type ConsoleAccount struct { + ID Int64Value `json:"id"` + LoginName string `json:"loginName"` + Nickname string `json:"nickname"` + Token string `json:"token"` +} + type UserProfile struct { ID Int64Value `json:"id"` UserAvatar string `json:"userAvatar"` @@ -88,6 +95,41 @@ type GrantPropsRequest struct { SourceGroupID int64 `json:"sourceGroupId"` } +type SendActivityRewardRequest struct { + TrackID int64 `json:"trackId"` + Origin string `json:"origin"` + SysOrigin string `json:"sysOrigin"` + SourceGroupID int64 `json:"sourceGroupId"` + AcceptUserID int64 `json:"acceptUserId"` +} + +// OfficialNoticeCustomizeRequest 表示发送自定义官方通知的请求体。 +type OfficialNoticeCustomizeRequest struct { + ToAccounts []int64 `json:"toAccounts"` + LangCode string `json:"langCode,omitempty"` + NoticeType string `json:"noticeType"` + Expand any `json:"expand,omitempty"` + Title string `json:"title"` + Content string `json:"content"` +} + +type RewardGroupItem struct { + ID Int64Value `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Quantity Int64Value `json:"quantity"` + Cover string `json:"cover"` + Remark string `json:"remark"` +} + +type RewardGroupDetail struct { + ID Int64Value `json:"id"` + Name string `json:"name"` + SysOrigin string `json:"sysOrigin"` + RewardConfigList []RewardGroupItem `json:"rewardConfigList"` +} + type resultResponse[T any] struct { Code int `json:"code"` Message string `json:"message"` @@ -118,7 +160,7 @@ func New(cfg config.Config) *Client { return &Client{ cfg: cfg, httpClient: &http.Client{ - Timeout: cfg.Timeout, + Timeout: cfg.HTTP.Timeout, }, } } @@ -129,7 +171,7 @@ func (c *Client) AuthenticateToken(ctx context.Context, token string) (UserCrede return UserCredential{}, err } - endpoint := c.cfg.JavaAuthBaseURL + "/auth/client/getUserCredential?userId=" + + endpoint := c.cfg.Java.AuthBaseURL + "/auth/client/getUserCredential?userId=" + url.QueryEscape(strconv.FormatInt(int64(tokenCredential.UserID), 10)) var resp resultResponse[UserCredential] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { @@ -150,6 +192,27 @@ func (c *Client) AuthenticateToken(ctx context.Context, token string) (UserCrede return tokenCredential, nil } +// AuthenticateConsoleToken 校验后台 console Bearer token,并返回管理员基础信息。 +func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { + authorization = strings.TrimSpace(authorization) + if authorization == "" { + return ConsoleAccount{}, fmt.Errorf("authorization is blank") + } + + endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info" + headers := http.Header{} + headers.Set("Authorization", authorization) + + var resp resultResponse[ConsoleAccount] + if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { + return ConsoleAccount{}, err + } + if int64(resp.Body.ID) == 0 { + return ConsoleAccount{}, fmt.Errorf("empty console account response") + } + return resp.Body, nil +} + func parseUserCredentialToken(token string) (UserCredential, error) { token = strings.TrimSpace(token) if token == "" { @@ -208,7 +271,7 @@ func parseUserCredentialToken(token string) (UserCredential, error) { } func (c *Client) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { - endpoint := c.cfg.JavaDeviceBaseURL + "/user/device/fingerprint?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + endpoint := c.cfg.Java.DeviceBaseURL + "/user/device/fingerprint?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[string] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return "", err @@ -217,7 +280,7 @@ func (c *Client) GetDeviceFingerprint(ctx context.Context, userID int64) (string } func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { - endpoint := c.cfg.JavaAppBaseURL + "/activity/invite/user/my/invite/code" + endpoint := c.cfg.Java.AppBaseURL + "/activity/invite/user/my/invite/code" headers := http.Header{} if authorization != "" { headers.Set("Authorization", authorization) @@ -233,15 +296,36 @@ func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (In } func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error { - return c.postJSON(ctx, c.cfg.JavaBridgeBaseURL+"/internal/resident-activity/invite/grant-gold", req, nil, nil) + return c.postJSON(ctx, c.cfg.Java.BridgeBaseURL+"/internal/resident-activity/invite/grant-gold", req, nil, nil) } func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error { - return c.postJSON(ctx, c.cfg.JavaBridgeBaseURL+"/internal/resident-activity/invite/grant-props", req, nil, nil) + return c.postJSON(ctx, c.cfg.Java.BridgeBaseURL+"/internal/resident-activity/invite/grant-props", req, nil, nil) +} + +func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { + return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil) +} + +// SendOfficialNoticeCustomize 调用 external 服务发送自定义官方通知。 +func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNoticeCustomizeRequest) error { + return c.postJSON(ctx, c.cfg.Java.ExternalBaseURL+"/official-notice/client/send/customize/template", req, nil, nil) +} + +func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { + endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10)) + var resp resultResponse[RewardGroupDetail] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return RewardGroupDetail{}, err + } + if int64(resp.Body.ID) == 0 { + return RewardGroupDetail{}, fmt.Errorf("empty reward group response") + } + return resp.Body, nil } func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { - endpoint := c.cfg.JavaOtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[UserProfile] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return UserProfile{}, err @@ -253,7 +337,7 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, } func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, error) { - endpoint := c.cfg.JavaOtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[string] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return "", err @@ -262,7 +346,7 @@ func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, err } func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { - endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/mapBalance" + endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance" var resp resultResponse[map[string]int64] if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil { return nil, err @@ -279,7 +363,7 @@ func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64 } func (c *Client) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { - endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/existsEventId?eventId=" + url.QueryEscape(eventID) + endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/existsEventId?eventId=" + url.QueryEscape(eventID) var resp resultResponse[bool] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return false, err @@ -288,7 +372,7 @@ func (c *Client) ExistsGoldEvent(ctx context.Context, eventID string) (bool, err } func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { - endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/balance/change" + endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/balance/change" if err := c.postJSON(ctx, endpoint, cmd, nil, nil); err == nil { return nil } else if !shouldFallbackToGoldTestEndpoint(err) { @@ -307,7 +391,7 @@ func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) Amount: cmd.Amount.DollarAmount, CloseDelayAsset: cmd.CloseDelayAsset, } - return c.postJSON(ctx, c.cfg.JavaWalletBaseURL+"/wallet/gold/client/balance/change/test", testCmd, nil, nil) + return c.postJSON(ctx, c.cfg.Java.WalletBaseURL+"/wallet/gold/client/balance/change/test", testCmd, nil, nil) } func NewPennyAmountPayloadFromDollar(amount int64) PennyAmountPayload { diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go index beb664e..e53ad71 100644 --- a/internal/model/baishun_models.go +++ b/internal/model/baishun_models.go @@ -2,161 +2,175 @@ package model import "time" +// SysGameListConfig 是系统游戏列表主表,描述平台可展示的游戏。 type SysGameListConfig struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32"` - GameOrigin string `gorm:"column:game_origin;size:32"` - GameID string `gorm:"column:game_id;size:64"` - Name string `gorm:"column:name;size:128"` - Category string `gorm:"column:category;size:64"` - GameCode string `gorm:"column:game_code;size:512"` - Cover string `gorm:"column:cover;size:1024"` - Showcase bool `gorm:"column:is_showcase"` - Sort int64 `gorm:"column:sort"` - FullScreen bool `gorm:"column:full_screen"` - ClientOrigin string `gorm:"column:client_origin;size:32"` - Regions string `gorm:"column:regions;size:255"` - GameMode string `gorm:"column:game_mode;size:255"` + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识 + GameOrigin string `gorm:"column:game_origin;size:32"` // 游戏来源 + GameID string `gorm:"column:game_id;size:64"` // 内部游戏 ID + Name string `gorm:"column:name;size:128"` // 游戏名称 + Category string `gorm:"column:category;size:64"` // 分类 + GameCode string `gorm:"column:game_code;size:512"` // 游戏码或入口标识 + Cover string `gorm:"column:cover;size:1024"` // 封面图 + Showcase bool `gorm:"column:is_showcase"` // 是否首页推荐 + Sort int64 `gorm:"column:sort"` // 排序值 + FullScreen bool `gorm:"column:full_screen"` // 是否全屏展示 + ClientOrigin string `gorm:"column:client_origin;size:32"` // 客户端来源 + Regions string `gorm:"column:regions;size:255"` // 可投放地区 + GameMode string `gorm:"column:game_mode;size:255"` // 游戏模式配置 } +// TableName 返回系统游戏列表表名。 func (SysGameListConfig) TableName() string { return "sys_game_list_config" } +// SysGameListVendorExt 保存某个厂商在系统游戏表上的扩展信息。 type SysGameListVendorExt struct { - ID int64 `gorm:"column:id;primaryKey"` - GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1"` - VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2"` - VendorGameID string `gorm:"column:vendor_game_id;size:64"` - LaunchMode string `gorm:"column:launch_mode;size:32"` - PackageVersion string `gorm:"column:package_version;size:32"` - PackageURL string `gorm:"column:package_url;size:1024"` - PreviewURL string `gorm:"column:preview_url;size:1024"` - Orientation *int `gorm:"column:orientation"` - SafeHeight int `gorm:"column:safe_height"` - GSP string `gorm:"column:gsp;size:64"` - BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` - CurrencyType *int `gorm:"column:currency_type"` - CurrencyIcon string `gorm:"column:currency_icon;size:1024"` - ExtraJSON string `gorm:"column:extra_json;type:text"` - Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 主键 + GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` // 关联系统游戏配置 ID + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1"` // 系统标识 + VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2"` // 厂商类型 + VendorGameID string `gorm:"column:vendor_game_id;size:64"` // 厂商游戏 ID + LaunchMode string `gorm:"column:launch_mode;size:32"` // 启动模式 + PackageVersion string `gorm:"column:package_version;size:32"` // 包版本号 + PackageURL string `gorm:"column:package_url;size:1024"` // 包下载地址 + PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 + Orientation *int `gorm:"column:orientation"` // 屏幕方向 + SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 + GSP string `gorm:"column:gsp;size:64"` // GSP 配置 + BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` // Bridge 协议版本 + CurrencyType *int `gorm:"column:currency_type"` // 货币类型 + CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标 + ExtraJSON string `gorm:"column:extra_json;type:text"` // 额外扩展配置 + Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3"` // 是否启用 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回游戏厂商扩展表名。 func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ext" } +// BaishunGameCatalog 是百顺平台同步到本地的游戏目录。 type BaishunGameCatalog struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:1;index:idx_baishun_catalog_internal_game,priority:1"` - InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_internal_game,priority:2"` - VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:2"` - Name string `gorm:"column:name;size:128"` - Cover string `gorm:"column:cover;size:1024"` - PreviewURL string `gorm:"column:preview_url;size:1024"` - DownloadURL string `gorm:"column:download_url;size:1024"` - PackageVersion string `gorm:"column:package_version;size:32"` - GameModeJSON string `gorm:"column:game_mode_json;size:64"` - Orientation *int `gorm:"column:orientation"` - SafeHeight int `gorm:"column:safe_height"` - VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` - GSP string `gorm:"column:gsp;size:64"` - RawJSON string `gorm:"column:raw_json;type:longtext"` - Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_internal_game,priority:3"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:1;index:idx_baishun_catalog_internal_game,priority:1"` // 系统标识 + InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_internal_game,priority:2"` // 内部游戏 ID + VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:2"` // 百顺游戏 ID + Name string `gorm:"column:name;size:128"` // 游戏名称 + Cover string `gorm:"column:cover;size:1024"` // 封面图 + PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 + DownloadURL string `gorm:"column:download_url;size:1024"` // 下载地址 + PackageVersion string `gorm:"column:package_version;size:32"` // 包版本 + GameModeJSON string `gorm:"column:game_mode_json;size:64"` // 游戏模式原始 JSON + Orientation *int `gorm:"column:orientation"` // 屏幕方向 + SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 + VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` // 场馆等级 JSON + GSP string `gorm:"column:gsp;size:64"` // GSP 配置 + RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录数据 + Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_internal_game,priority:3"` // 状态 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回百顺目录表名。 func (BaishunGameCatalog) TableName() string { return "baishun_game_catalog" } +// BaishunLaunchSession 保存一次房间内游戏启动会话。 type BaishunLaunchSession struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_baishun_session_room_user,priority:1"` - RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_session_room_user,priority:2"` - UserID int64 `gorm:"column:user_id;index:idx_baishun_session_room_user,priority:3"` - InternalGameID string `gorm:"column:internal_game_id;size:64"` - VendorGameID int `gorm:"column:vendor_game_id"` - GameSessionID string `gorm:"column:game_session_id;size:64;uniqueIndex:uk_baishun_game_session"` - LaunchCode string `gorm:"column:launch_code;size:128;uniqueIndex:uk_baishun_launch_code"` - LaunchCodeExpireTime time.Time `gorm:"column:launch_code_expire_time"` - SSToken *string `gorm:"column:ss_token;size:255;uniqueIndex:uk_baishun_ss_token"` - SSTokenExpireTime *time.Time `gorm:"column:ss_token_expire_time;index:idx_baishun_session_token_expire"` - Language string `gorm:"column:language;size:16"` - GSP int `gorm:"column:gsp"` - CurrencyType int `gorm:"column:currency_type"` - CurrencyIcon string `gorm:"column:currency_icon;size:1024"` - SceneMode int `gorm:"column:scene_mode"` - GameMode int `gorm:"column:game_mode"` - PackageVersion string `gorm:"column:package_version;size:32"` - ClientIP string `gorm:"column:client_ip;size:64"` - ClientOrigin string `gorm:"column:client_origin;size:16"` - Status string `gorm:"column:status;size:32;index:idx_baishun_session_room_user,priority:4"` - ClosedReason string `gorm:"column:closed_reason;size:64"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 会话主键 + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_baishun_session_room_user,priority:1"` // 系统标识 + RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_session_room_user,priority:2"` // 房间 ID + UserID int64 `gorm:"column:user_id;index:idx_baishun_session_room_user,priority:3"` // 发起用户 ID + InternalGameID string `gorm:"column:internal_game_id;size:64"` // 内部游戏 ID + VendorGameID int `gorm:"column:vendor_game_id"` // 厂商游戏 ID + GameSessionID string `gorm:"column:game_session_id;size:64;uniqueIndex:uk_baishun_game_session"` // 业务会话 ID + LaunchCode string `gorm:"column:launch_code;size:128;uniqueIndex:uk_baishun_launch_code"` // 首次启动 code + LaunchCodeExpireTime time.Time `gorm:"column:launch_code_expire_time"` // code 过期时间 + SSToken *string `gorm:"column:ss_token;size:255;uniqueIndex:uk_baishun_ss_token"` // 百顺 ss_token + SSTokenExpireTime *time.Time `gorm:"column:ss_token_expire_time;index:idx_baishun_session_token_expire"` // token 过期时间 + Language string `gorm:"column:language;size:16"` // 语言 + GSP int `gorm:"column:gsp"` // GSP 值 + CurrencyType int `gorm:"column:currency_type"` // 货币类型 + CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标 + SceneMode int `gorm:"column:scene_mode"` // 场景模式 + GameMode int `gorm:"column:game_mode"` // 游戏模式 + PackageVersion string `gorm:"column:package_version;size:32"` // 包版本 + ClientIP string `gorm:"column:client_ip;size:64"` // 客户端 IP + ClientOrigin string `gorm:"column:client_origin;size:16"` // 客户端来源 + Status string `gorm:"column:status;size:32;index:idx_baishun_session_room_user,priority:4"` // 会话状态 + ClosedReason string `gorm:"column:closed_reason;size:64"` // 关闭原因 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回百顺启动会话表名。 func (BaishunLaunchSession) TableName() string { return "baishun_launch_session" } +// BaishunOrderIdempotency 记录百顺扣增币回调的幂等处理结果。 type BaishunOrderIdempotency struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_order,priority:1;index:idx_baishun_order_user_time,priority:1"` - OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_baishun_order,priority:2"` - GameRoundID string `gorm:"column:game_round_id;size:128;index:idx_baishun_round"` - RoomID string `gorm:"column:room_id;size:64"` - UserID int64 `gorm:"column:user_id;index:idx_baishun_order_user_time,priority:2"` - VendorGameID int `gorm:"column:vendor_game_id"` - CurrencyDiff int64 `gorm:"column:currency_diff"` - DiffMsg string `gorm:"column:diff_msg;size:32"` - MsgType string `gorm:"column:msg_type;size:64"` - CurrencyType *int `gorm:"column:currency_type"` - WalletEventID string `gorm:"column:wallet_event_id;size:160;uniqueIndex:uk_baishun_wallet_event"` - WalletAssetRecordID *int64 `gorm:"column:wallet_asset_record_id"` - Status string `gorm:"column:status;size:32"` - RequestJSON string `gorm:"column:request_json;type:longtext"` - ResponseJSON string `gorm:"column:response_json;type:longtext"` - CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_order_user_time,priority:3"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_order,priority:1;index:idx_baishun_order_user_time,priority:1"` // 系统标识 + OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_baishun_order,priority:2"` // 订单 ID + GameRoundID string `gorm:"column:game_round_id;size:128;index:idx_baishun_round"` // 局号 + RoomID string `gorm:"column:room_id;size:64"` // 房间 ID + UserID int64 `gorm:"column:user_id;index:idx_baishun_order_user_time,priority:2"` // 用户 ID + VendorGameID int `gorm:"column:vendor_game_id"` // 厂商游戏 ID + CurrencyDiff int64 `gorm:"column:currency_diff"` // 货币变化值 + DiffMsg string `gorm:"column:diff_msg;size:32"` // 变化说明 + MsgType string `gorm:"column:msg_type;size:64"` // 消息类型 + CurrencyType *int `gorm:"column:currency_type"` // 货币类型 + WalletEventID string `gorm:"column:wallet_event_id;size:160;uniqueIndex:uk_baishun_wallet_event"` // 钱包事件 ID + WalletAssetRecordID *int64 `gorm:"column:wallet_asset_record_id"` // 钱包资产记录 ID + Status string `gorm:"column:status;size:32"` // 处理状态 + RequestJSON string `gorm:"column:request_json;type:longtext"` // 原始请求 + ResponseJSON string `gorm:"column:response_json;type:longtext"` // 回包结果 + CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_order_user_time,priority:3"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回百顺订单幂等表名。 func (BaishunOrderIdempotency) TableName() string { return "baishun_order_idempotency" } +// BaishunCallbackLog 保存百顺各类回调请求和响应,便于审计排查。 type BaishunCallbackLog struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32"` - RequestType string `gorm:"column:request_type;size:32;index:idx_baishun_log_type_time,priority:1"` - OrderID string `gorm:"column:order_id;size:128;index:idx_baishun_log_order"` - GameRoundID string `gorm:"column:game_round_id;size:128"` - RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_log_room_user,priority:1"` - UserID *int64 `gorm:"column:user_id;index:idx_baishun_log_room_user,priority:2"` - VendorGameID *int `gorm:"column:vendor_game_id"` - RequestJSON string `gorm:"column:request_json;type:longtext"` - ResponseJSON string `gorm:"column:response_json;type:longtext"` - BizCode string `gorm:"column:biz_code;size:32"` - BizMessage string `gorm:"column:biz_message;type:text"` - Status string `gorm:"column:status;size:32"` - CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_log_type_time,priority:2"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 日志主键 + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识 + RequestType string `gorm:"column:request_type;size:32;index:idx_baishun_log_type_time,priority:1"` // 回调类型 + OrderID string `gorm:"column:order_id;size:128;index:idx_baishun_log_order"` // 订单 ID + GameRoundID string `gorm:"column:game_round_id;size:128"` // 局号 + RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_log_room_user,priority:1"` // 房间 ID + UserID *int64 `gorm:"column:user_id;index:idx_baishun_log_room_user,priority:2"` // 用户 ID + VendorGameID *int `gorm:"column:vendor_game_id"` // 厂商游戏 ID + RequestJSON string `gorm:"column:request_json;type:longtext"` // 请求报文 + ResponseJSON string `gorm:"column:response_json;type:longtext"` // 响应报文 + BizCode string `gorm:"column:biz_code;size:32"` // 业务状态码 + BizMessage string `gorm:"column:biz_message;type:text"` // 业务描述 + Status string `gorm:"column:status;size:32"` // 处理状态 + CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_log_type_time,priority:2"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回百顺回调日志表名。 func (BaishunCallbackLog) TableName() string { return "baishun_callback_log" } +// BaishunRoomState 保存房间当前挂载的游戏状态。 type BaishunRoomState struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_room_state,priority:1"` - RoomID string `gorm:"column:room_id;size:64;uniqueIndex:uk_baishun_room_state,priority:2"` - HostUserID int64 `gorm:"column:host_user_id"` - CurrentGameID string `gorm:"column:current_game_id;size:64"` - CurrentVendorGameID *int `gorm:"column:current_vendor_game_id"` - CurrentGameName string `gorm:"column:current_game_name;size:128"` - CurrentGameCover string `gorm:"column:current_game_cover;size:1024"` - GameSessionID string `gorm:"column:game_session_id;size:64;index:idx_baishun_room_state_session"` - LaunchUserID *int64 `gorm:"column:launch_user_id"` - State string `gorm:"column:state;size:32"` - ExtraJSON string `gorm:"column:extra_json;type:text"` - StartTime *time.Time `gorm:"column:start_time"` - EndTime *time.Time `gorm:"column:end_time"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_room_state,priority:1"` // 系统标识 + RoomID string `gorm:"column:room_id;size:64;uniqueIndex:uk_baishun_room_state,priority:2"` // 房间 ID + HostUserID int64 `gorm:"column:host_user_id"` // 房主用户 ID + CurrentGameID string `gorm:"column:current_game_id;size:64"` // 当前内部游戏 ID + CurrentVendorGameID *int `gorm:"column:current_vendor_game_id"` // 当前厂商游戏 ID + CurrentGameName string `gorm:"column:current_game_name;size:128"` // 当前游戏名 + CurrentGameCover string `gorm:"column:current_game_cover;size:1024"` // 当前游戏封面 + GameSessionID string `gorm:"column:game_session_id;size:64;index:idx_baishun_room_state_session"` // 当前会话 ID + LaunchUserID *int64 `gorm:"column:launch_user_id"` // 发起启动的用户 ID + State string `gorm:"column:state;size:32"` // 房间游戏状态 + ExtraJSON string `gorm:"column:extra_json;type:text"` // 扩展信息 + StartTime *time.Time `gorm:"column:start_time"` // 开始时间 + EndTime *time.Time `gorm:"column:end_time"` // 结束时间 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } +// TableName 返回百顺房间状态表名。 func (BaishunRoomState) TableName() string { return "baishun_room_state" } diff --git a/internal/model/common_models.go b/internal/model/common_models.go new file mode 100644 index 0000000..fdfc493 --- /dev/null +++ b/internal/model/common_models.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// InviteCodeMapping 维护用户和邀请码的一对一映射关系。 +type InviteCodeMapping struct { + UserID int64 `gorm:"column:user_id;primaryKey"` // 用户 ID + InviteCode string `gorm:"column:invite_code;size:32;uniqueIndex:uk_invite_code"` // 邀请码 + Account string `gorm:"column:account;size:64"` // 用户账号 +} + +// TableName 返回邀请码映射表名。 +func (InviteCodeMapping) TableName() string { return "invite_code_mapping" } + +// UserBaseInfo 保存用户基础资料,供活动和榜单补齐展示信息。 +type UserBaseInfo struct { + ID int64 `gorm:"column:id;primaryKey"` // 用户 ID + Account string `gorm:"column:account;size:64"` // 用户账号 + UserAvatar string `gorm:"column:user_avatar;size:255"` // 用户头像 + UserNickname string `gorm:"column:user_nickname;size:255"` // 用户昵称 + OriginSys string `gorm:"column:origin_sys;size:32"` // 来源系统 + CountryCode string `gorm:"column:country_code;size:32"` // 国家编码 + CountryName string `gorm:"column:country_name;size:128"` // 国家名称 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 +} + +// TableName 返回用户基础资料表名。 +func (UserBaseInfo) TableName() string { return "user_base_info" } diff --git a/internal/model/invite_models.go b/internal/model/invite_models.go new file mode 100644 index 0000000..521e797 --- /dev/null +++ b/internal/model/invite_models.go @@ -0,0 +1,150 @@ +package model + +import "time" + +// InviteCampaignConfig 保存邀请活动主配置。 +type InviteCampaignConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 配置主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_campaign_sys_origin"` // 系统标识 + Enabled bool `gorm:"column:enabled"` // 活动开关 + DownloadURL string `gorm:"column:download_url;size:512"` // 下载链接 + ShareTitle string `gorm:"column:share_title;size:255"` // 分享标题 + ShareDesc string `gorm:"column:share_desc;size:255"` // 分享描述 + ServiceContact string `gorm:"column:service_contact;size:255"` // 客服联系方式 + Timezone string `gorm:"column:timezone;size:64"` // 活动时区 + AntiCheatSameIPOnce bool `gorm:"column:anti_cheat_same_ip_once"` // 同 IP 仅允许命中一次 + AntiCheatSameDeviceOnce bool `gorm:"column:anti_cheat_same_device_once"` // 同设备仅允许命中一次 + 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"` // 更新用户 +} + +// TableName 返回邀请活动配置表名。 +func (InviteCampaignConfig) TableName() string { return "invite_campaign_config" } + +// InviteCampaignRewardRule 保存邀请活动奖励规则。 +type InviteCampaignRewardRule struct { + ID int64 `gorm:"column:id;primaryKey"` // 规则主键 + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_rule_sys_type_sort,priority:1"` // 系统标识 + RuleType string `gorm:"column:rule_type;size:32;index:idx_invite_rule_sys_type_sort,priority:2"` // 规则类型 + ThresholdValue int64 `gorm:"column:threshold_value"` // 达标阈值 + GoldAmount int64 `gorm:"column:gold_amount"` // 金币奖励 + RewardGroupID *int64 `gorm:"column:reward_group_id"` // 道具奖励组 ID + Enabled bool `gorm:"column:enabled"` // 规则是否启用 + Sort int `gorm:"column:sort;index:idx_invite_rule_sys_type_sort,priority:3"` // 展示排序 + 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"` // 更新用户 +} + +// TableName 返回邀请活动奖励规则表名。 +func (InviteCampaignRewardRule) TableName() string { return "invite_campaign_reward_rule" } + +// InviteCampaignRelation 记录邀请人和被邀请人的绑定关系。 +type InviteCampaignRelation struct { + ID int64 `gorm:"column:id;primaryKey"` // 关系主键 + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_relation_sys_inviter,priority:1;index:uk_invite_relation_invitee,priority:1,unique"` // 系统标识 + InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invite_relation_sys_inviter,priority:2"` // 邀请人用户 ID + InviteeUserID int64 `gorm:"column:invitee_user_id;index:uk_invite_relation_invitee,priority:2,unique"` // 被邀请人用户 ID + InviteCode string `gorm:"column:invite_code;size:32"` // 绑定时使用的邀请码 + BindIP string `gorm:"column:bind_ip;size:128;index:idx_invite_relation_ip"` // 绑定 IP + DeviceFingerprint string `gorm:"column:device_fingerprint;size:255;index:idx_invite_relation_device"` // 设备指纹 + EligibleStatus string `gorm:"column:eligible_status;size:32"` // 是否符合奖励资格 + BlockReason string `gorm:"column:block_reason;size:255"` // 被风控拦截原因 + BindTime time.Time `gorm:"column:bind_time"` // 绑定时间 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回邀请关系表名。 +func (InviteCampaignRelation) TableName() string { return "invite_campaign_relation" } + +// InviteCampaignInviteeProgress 维护被邀请人的充值进度与有效状态。 +type InviteCampaignInviteeProgress struct { + ID int64 `gorm:"column:id;primaryKey"` // 进度主键 + RelationID int64 `gorm:"column:relation_id;uniqueIndex:uk_invitee_progress_relation"` // 关联邀请关系 ID + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识 + InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invitee_progress_inviter"` // 邀请人用户 ID + InviteeUserID int64 `gorm:"column:invitee_user_id;uniqueIndex:uk_invitee_progress_invitee"` // 被邀请人用户 ID + TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币 + ValidUser bool `gorm:"column:valid_user"` // 是否已成为有效用户 + ValidTime *time.Time `gorm:"column:valid_time"` // 成为有效用户的时间 + RewardedRuleIDs string `gorm:"column:rewarded_rule_ids;size:255"` // 已发放的规则 ID 集合 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回被邀请人进度表名。 +func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" } + +// InviteCampaignMonthlyProgress 汇总邀请人月度邀请进展。 +type InviteCampaignMonthlyProgress struct { + ID int64 `gorm:"column:id;primaryKey"` // 统计主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` // 系统标识 + InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_monthly_progress,priority:2"` // 邀请人用户 ID + MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_monthly_progress,priority:3"` // 月份标识 + InviteCount int64 `gorm:"column:invite_count"` // 邀请人数 + ValidUserCount int64 `gorm:"column:valid_user_count"` // 有效用户数 + TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币 + RewardGoldCoins int64 `gorm:"column:reward_gold_coins"` // 累计发放金币 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回邀请月度进度表名。 +func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" } + +// InviteCampaignTaskClaim 记录任务奖励领取行为,防止重复领取。 +type InviteCampaignTaskClaim struct { + ID int64 `gorm:"column:id;primaryKey"` // 领取记录主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` // 系统标识 + InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_task_claim,priority:2"` // 邀请人用户 ID + MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_task_claim,priority:3"` // 月份标识 + RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` // 规则 ID + ClaimTime time.Time `gorm:"column:claim_time"` // 领取时间 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回邀请任务领取表名。 +func (InviteCampaignTaskClaim) TableName() string { return "invite_campaign_task_claim" } + +// InviteCampaignRewardLog 记录邀请活动金币或道具发奖明细。 +type InviteCampaignRewardLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 发奖日志主键 + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_reward_target"` // 系统标识 + RelationID *int64 `gorm:"column:relation_id"` // 关联邀请关系 ID + InviterUserID *int64 `gorm:"column:inviter_user_id"` // 邀请人用户 ID + InviteeUserID *int64 `gorm:"column:invitee_user_id"` // 被邀请人用户 ID + TargetUserID int64 `gorm:"column:target_user_id;index:idx_invite_reward_target"` // 奖励接收用户 ID + RuleID *int64 `gorm:"column:rule_id"` // 命中的规则 ID + RewardScene string `gorm:"column:reward_scene;size:32"` // 奖励场景 + BusinessKey string `gorm:"column:business_key;size:128;uniqueIndex:uk_invite_reward_business"` // 业务幂等键 + GoldAmount int64 `gorm:"column:gold_amount"` // 金币奖励数 + RewardGroupID *int64 `gorm:"column:reward_group_id"` // 奖励组 ID + Status string `gorm:"column:status;size:32"` // 发奖状态 + ErrorMessage string `gorm:"column:error_message;size:255"` // 错误信息 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回邀请发奖日志表名。 +func (InviteCampaignRewardLog) TableName() string { return "invite_campaign_reward_log" } + +// InviteCampaignRechargeEvent 保存被邀请人的充值事件,用于累计进度和奖励判定。 +type InviteCampaignRechargeEvent struct { + ID int64 `gorm:"column:id;primaryKey"` // 充值事件主键 + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识 + OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_invite_recharge_order"` // 订单 ID + InviterUserID *int64 `gorm:"column:inviter_user_id"` // 邀请人用户 ID + InviteeUserID int64 `gorm:"column:invitee_user_id"` // 被邀请人用户 ID + RechargeCoins int64 `gorm:"column:recharge_coins"` // 本次充值金币 + PayTime time.Time `gorm:"column:pay_time"` // 支付时间 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回邀请充值事件表名。 +func (InviteCampaignRechargeEvent) TableName() string { return "invite_campaign_recharge_event" } diff --git a/internal/model/luckygift_models.go b/internal/model/luckygift_models.go new file mode 100644 index 0000000..bd556a6 --- /dev/null +++ b/internal/model/luckygift_models.go @@ -0,0 +1,49 @@ +package model + +import "time" + +// LuckyGiftDrawRequestRecord 保存幸运礼物抽奖主请求及其处理状态。 +type LuckyGiftDrawRequestRecord struct { + ID int64 `gorm:"column:id;primaryKey"` // 抽奖请求主键 + BusinessID string `gorm:"column:business_id;size:64;uniqueIndex:uk_lucky_gift_business"` // 业务幂等 ID + ConsumeAssetRecordID string `gorm:"column:consume_asset_record_id;size:64"` // 消耗资产记录 ID + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_lucky_gift_sender,priority:1"` // 系统标识 + StandardID int64 `gorm:"column:standard_id"` // 配置档位 ID + RoomID int64 `gorm:"column:room_id"` // 房间 ID + SendUserID int64 `gorm:"column:send_user_id;index:idx_lucky_gift_sender,priority:2"` // 送礼用户 ID + GiftID int64 `gorm:"column:gift_id"` // 礼物 ID + GiftPrice int64 `gorm:"column:gift_price"` // 礼物单价 + GiftNum int64 `gorm:"column:gift_num"` // 礼物数量 + GiftIsFree bool `gorm:"column:gift_is_free"` // 是否免费礼物 + ProviderCode int `gorm:"column:provider_code"` // 三方返回码 + RewardTotal int64 `gorm:"column:reward_total"` // 总中奖金币 + BalanceAfter int64 `gorm:"column:balance_after"` // 返奖后余额 + WalletEventID string `gorm:"column:wallet_event_id;size:128;uniqueIndex:uk_lucky_gift_wallet_event"` // 钱包事件 ID + Status string `gorm:"column:status;size:32;index:idx_lucky_gift_status"` // 请求状态 + ErrorMessage string `gorm:"column:error_message;size:1024"` // 错误信息 + RequestJSON string `gorm:"column:request_json;type:longtext"` // 原始请求报文 + ProviderResponseJSON string `gorm:"column:provider_response_json;type:longtext"` // 三方返回报文 + ResponseJSON string `gorm:"column:response_json;type:longtext"` // 对外响应报文 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回幸运礼物抽奖请求表名。 +func (LuckyGiftDrawRequestRecord) TableName() string { return "lucky_gift_draw_request" } + +// LuckyGiftDrawOrderRecord 保存每个收礼人的抽奖结果明细。 +type LuckyGiftDrawOrderRecord struct { + ID int64 `gorm:"column:id;primaryKey"` // 明细主键 + BusinessID string `gorm:"column:business_id;size:64;uniqueIndex:uk_lucky_gift_order,priority:1;index:idx_lucky_gift_order_business"` // 业务幂等 ID + OrderSeed string `gorm:"column:order_seed;size:64;uniqueIndex:uk_lucky_gift_order,priority:2"` // 单条订单种子 + SortNo int `gorm:"column:sort_no"` // 排序号 + AcceptUserID int64 `gorm:"column:accept_user_id"` // 收礼用户 ID + ProviderOrderID string `gorm:"column:provider_order_id;size:64"` // 三方订单 ID + IsWin bool `gorm:"column:is_win"` // 是否中奖 + RewardNum int64 `gorm:"column:reward_num"` // 中奖数量 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回幸运礼物抽奖明细表名。 +func (LuckyGiftDrawOrderRecord) TableName() string { return "lucky_gift_draw_order" } diff --git a/internal/model/models.go b/internal/model/models.go deleted file mode 100644 index a9685ed..0000000 --- a/internal/model/models.go +++ /dev/null @@ -1,177 +0,0 @@ -package model - -import "time" - -type InviteCodeMapping struct { - UserID int64 `gorm:"column:user_id;primaryKey"` - InviteCode string `gorm:"column:invite_code;size:32;uniqueIndex:uk_invite_code"` - Account string `gorm:"column:account;size:64"` -} - -func (InviteCodeMapping) TableName() string { return "invite_code_mapping" } - -type UserBaseInfo struct { - ID int64 `gorm:"column:id;primaryKey"` - Account string `gorm:"column:account;size:64"` - UserAvatar string `gorm:"column:user_avatar;size:255"` - UserNickname string `gorm:"column:user_nickname;size:255"` - OriginSys string `gorm:"column:origin_sys;size:32"` - CreateTime time.Time `gorm:"column:create_time"` -} - -func (UserBaseInfo) TableName() string { return "user_base_info" } - -type ResidentRegisterRewardConfig struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_resident_register_reward_sys_origin"` - Enabled bool `gorm:"column:enabled"` - GoldAmount int64 `gorm:"column:gold_amount"` - RewardGroupID *int64 `gorm:"column:reward_group_id"` - CreateTime time.Time `gorm:"column:create_timestamp"` - UpdateTime time.Time `gorm:"column:update_timestamp"` -} - -func (ResidentRegisterRewardConfig) TableName() string { return "resident_register_reward_config" } - -type ResidentRegisterRewardLog struct { - ID int64 `gorm:"column:id;primaryKey"` - EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_register_reward_event"` - StreamMessageID string `gorm:"column:stream_message_id;size:64"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_register_reward_sys_user,priority:1"` - UserID int64 `gorm:"column:user_id;index:idx_register_reward_sys_user,priority:2"` - GoldAmount int64 `gorm:"column:gold_amount"` - RewardGroupID *int64 `gorm:"column:reward_group_id"` - Status string `gorm:"column:status;size:32"` - ErrorMessage string `gorm:"column:error_message;size:255"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (ResidentRegisterRewardLog) TableName() string { return "resident_register_reward_log" } - -type InviteCampaignConfig struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_campaign_sys_origin"` - Enabled bool `gorm:"column:enabled"` - DownloadURL string `gorm:"column:download_url;size:512"` - ShareTitle string `gorm:"column:share_title;size:255"` - ShareDesc string `gorm:"column:share_desc;size:255"` - ServiceContact string `gorm:"column:service_contact;size:255"` - Timezone string `gorm:"column:timezone;size:64"` - AntiCheatSameIPOnce bool `gorm:"column:anti_cheat_same_ip_once"` - AntiCheatSameDeviceOnce bool `gorm:"column:anti_cheat_same_device_once"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignConfig) TableName() string { return "invite_campaign_config" } - -type InviteCampaignRewardRule struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_rule_sys_type_sort,priority:1"` - RuleType string `gorm:"column:rule_type;size:32;index:idx_invite_rule_sys_type_sort,priority:2"` - ThresholdValue int64 `gorm:"column:threshold_value"` - GoldAmount int64 `gorm:"column:gold_amount"` - RewardGroupID *int64 `gorm:"column:reward_group_id"` - Enabled bool `gorm:"column:enabled"` - Sort int `gorm:"column:sort;index:idx_invite_rule_sys_type_sort,priority:3"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignRewardRule) TableName() string { return "invite_campaign_reward_rule" } - -type InviteCampaignRelation struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_relation_sys_inviter,priority:1;index:uk_invite_relation_invitee,priority:1,unique"` - InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invite_relation_sys_inviter,priority:2"` - InviteeUserID int64 `gorm:"column:invitee_user_id;index:uk_invite_relation_invitee,priority:2,unique"` - InviteCode string `gorm:"column:invite_code;size:32"` - BindIP string `gorm:"column:bind_ip;size:128;index:idx_invite_relation_ip"` - DeviceFingerprint string `gorm:"column:device_fingerprint;size:255;index:idx_invite_relation_device"` - EligibleStatus string `gorm:"column:eligible_status;size:32"` - BlockReason string `gorm:"column:block_reason;size:255"` - BindTime time.Time `gorm:"column:bind_time"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignRelation) TableName() string { return "invite_campaign_relation" } - -type InviteCampaignInviteeProgress struct { - ID int64 `gorm:"column:id;primaryKey"` - RelationID int64 `gorm:"column:relation_id;uniqueIndex:uk_invitee_progress_relation"` - SysOrigin string `gorm:"column:sys_origin;size:32"` - InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invitee_progress_inviter"` - InviteeUserID int64 `gorm:"column:invitee_user_id;uniqueIndex:uk_invitee_progress_invitee"` - TotalRecharge int64 `gorm:"column:total_recharge_coins"` - ValidUser bool `gorm:"column:valid_user"` - ValidTime *time.Time `gorm:"column:valid_time"` - RewardedRuleIDs string `gorm:"column:rewarded_rule_ids;size:255"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" } - -type InviteCampaignMonthlyProgress struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` - InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_monthly_progress,priority:2"` - MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_monthly_progress,priority:3"` - InviteCount int64 `gorm:"column:invite_count"` - ValidUserCount int64 `gorm:"column:valid_user_count"` - TotalRecharge int64 `gorm:"column:total_recharge_coins"` - RewardGoldCoins int64 `gorm:"column:reward_gold_coins"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" } - -type InviteCampaignTaskClaim struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` - InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_task_claim,priority:2"` - MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_task_claim,priority:3"` - RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` - ClaimTime time.Time `gorm:"column:claim_time"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignTaskClaim) TableName() string { return "invite_campaign_task_claim" } - -type InviteCampaignRewardLog struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_reward_target"` - RelationID *int64 `gorm:"column:relation_id"` - InviterUserID *int64 `gorm:"column:inviter_user_id"` - InviteeUserID *int64 `gorm:"column:invitee_user_id"` - TargetUserID int64 `gorm:"column:target_user_id;index:idx_invite_reward_target"` - RuleID *int64 `gorm:"column:rule_id"` - RewardScene string `gorm:"column:reward_scene;size:32"` - BusinessKey string `gorm:"column:business_key;size:128;uniqueIndex:uk_invite_reward_business"` - GoldAmount int64 `gorm:"column:gold_amount"` - RewardGroupID *int64 `gorm:"column:reward_group_id"` - Status string `gorm:"column:status;size:32"` - ErrorMessage string `gorm:"column:error_message;size:255"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignRewardLog) TableName() string { return "invite_campaign_reward_log" } - -type InviteCampaignRechargeEvent struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32"` - OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_invite_recharge_order"` - InviterUserID *int64 `gorm:"column:inviter_user_id"` - InviteeUserID int64 `gorm:"column:invitee_user_id"` - RechargeCoins int64 `gorm:"column:recharge_coins"` - PayTime time.Time `gorm:"column:pay_time"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` -} - -func (InviteCampaignRechargeEvent) TableName() string { return "invite_campaign_recharge_event" } diff --git a/internal/model/registerreward_models.go b/internal/model/registerreward_models.go new file mode 100644 index 0000000..ca3e2d0 --- /dev/null +++ b/internal/model/registerreward_models.go @@ -0,0 +1,40 @@ +package model + +import "time" + +// ResidentRegisterRewardConfig 保存常驻注册奖励配置。 +type ResidentRegisterRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 配置主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_resident_register_reward_sys_origin"` // 系统标识 + Enabled bool `gorm:"column:enabled"` // 配置是否启用 + GoldAmount int64 `gorm:"column:gold_amount"` // 注册赠送金币数量 + RewardGroupID *int64 `gorm:"column:reward_group_id"` // 道具奖励组 ID + DailyLimit int64 `gorm:"column:daily_limit"` // 每日可发放的奖励份数,0 表示不限 + 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"` // 更新用户 +} + +// TableName 返回注册奖励配置表名。 +func (ResidentRegisterRewardConfig) TableName() string { return "resident_register_reward_config" } + +// ResidentRegisterRewardLog 记录注册奖励消费链路的处理结果。 +type ResidentRegisterRewardLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 日志主键 + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_register_reward_event"` // 业务事件 ID + StreamMessageID string `gorm:"column:stream_message_id;size:64"` // Redis Stream 消息 ID + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_register_reward_sys_user,priority:1;index:idx_register_reward_claim,priority:1"` // 系统标识 + UserID int64 `gorm:"column:user_id;index:idx_register_reward_sys_user,priority:2"` // 用户 ID + ClaimDate string `gorm:"column:claim_date;size:10;index:idx_register_reward_claim,priority:2"` // 领取日期,格式为 YYYY-MM-DD + GoldAmount int64 `gorm:"column:gold_amount"` // 实际发放金币数 + RewardGroupID *int64 `gorm:"column:reward_group_id"` // 实际发放道具奖励组 ID + GrantMode string `gorm:"column:grant_mode;size:32;index:idx_register_reward_claim,priority:3"` // 发奖决策模式:ISSUE_REWARD / QUOTA_EXHAUSTED + Status string `gorm:"column:status;size:32"` // 处理状态 + ErrorMessage string `gorm:"column:error_message;size:255"` // 失败原因 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回注册奖励日志表名。 +func (ResidentRegisterRewardLog) TableName() string { return "resident_register_reward_log" } diff --git a/internal/model/weekstar_models.go b/internal/model/weekstar_models.go new file mode 100644 index 0000000..5f17d30 --- /dev/null +++ b/internal/model/weekstar_models.go @@ -0,0 +1,118 @@ +package model + +import "time" + +// WeekStarActivityConfig 对应周榜活动主配置,一条记录描述一个系统下的一个周榜活动时间窗。 +type WeekStarActivityConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 配置主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_week_star_sys_origin_window,priority:1;index:idx_week_star_active_lookup,priority:1"` // 系统标识,例如 LIKEI + Enabled bool `gorm:"column:enabled;index:idx_week_star_active_lookup,priority:2"` // 活动开关 + StartAt time.Time `gorm:"column:start_at;uniqueIndex:uk_week_star_sys_origin_window,priority:2;index:idx_week_star_active_lookup,priority:3"` // 活动开始时间 + EndAt time.Time `gorm:"column:end_at;uniqueIndex:uk_week_star_sys_origin_window,priority:3;index:idx_week_star_active_lookup,priority:4"` // 活动结束时间 + Timezone string `gorm:"column:timezone;size:64"` // 活动结算时区 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜活动主配置表名。 +func (WeekStarActivityConfig) TableName() string { return "week_star_activity_config" } + +// WeekStarActivityGiftConfig 对应周榜指定礼物配置,定义活动允许参与排行的礼物集合。 +type WeekStarActivityGiftConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 礼物配置主键 + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_week_star_gift_sort,priority:1;index:idx_week_star_gift_config,priority:1"` // 关联活动配置 ID + GiftID int64 `gorm:"column:gift_id;index:idx_week_star_gift_config,priority:2"` // 礼物 ID + GiftName string `gorm:"column:gift_name;size:255"` // 礼物名称 + GiftPhoto string `gorm:"column:gift_photo;size:1024"` // 礼物图片 + GiftCandy int64 `gorm:"column:gift_candy"` // 礼物金币单价 + Sort int `gorm:"column:sort;uniqueIndex:uk_week_star_gift_sort,priority:2"` // 展示顺序 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜指定礼物配置表名。 +func (WeekStarActivityGiftConfig) TableName() string { return "week_star_activity_gift_config" } + +// WeekStarActivityRewardConfig 对应周榜奖励配置,定义某个名次绑定哪个奖励组。 +type WeekStarActivityRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 奖励配置主键 + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_week_star_reward_rank,priority:1"` // 关联活动配置 ID + Rank int `gorm:"column:rank;uniqueIndex:uk_week_star_reward_rank,priority:2"` // 排名名次 + RewardGroupID int64 `gorm:"column:reward_group_id"` // 奖励组 ID + RewardGroupName string `gorm:"column:reward_group_name;size:255"` // 奖励组名称 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜奖励配置表名。 +func (WeekStarActivityRewardConfig) TableName() string { return "week_star_activity_reward_config" } + +// WeekStarGiftLog 记录每笔命中活动的送礼事件,用于幂等、审计和问题追踪。 +type WeekStarGiftLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 日志主键 + ConfigID int64 `gorm:"column:config_id;index:idx_week_star_gift_log_cycle,priority:1"` // 关联活动配置 ID + CycleKey string `gorm:"column:cycle_key;size:32;index:idx_week_star_gift_log_cycle,priority:2"` // 周期标识 + TrackID string `gorm:"column:track_id;size:128;uniqueIndex:uk_week_star_track_id"` // 送礼流水幂等键 + SendUserID int64 `gorm:"column:send_user_id;index:idx_week_star_gift_log_sender"` // 送礼用户 ID + GiftID int64 `gorm:"column:gift_id"` // 礼物 ID + Quantity int64 `gorm:"column:quantity"` // 礼物数量 + AcceptUserSize int64 `gorm:"column:accept_user_size"` // 收礼人数 + ScoreGold int64 `gorm:"column:score_gold"` // 本次累计金币积分 + EventTime time.Time `gorm:"column:event_time"` // 事件发生时间 + Status string `gorm:"column:status;size:32"` // 处理状态 + RawPayload string `gorm:"column:raw_payload;type:longtext"` // 原始消息体 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜送礼日志表名。 +func (WeekStarGiftLog) TableName() string { return "week_star_gift_log" } + +// WeekStarRankSnapshot 是每周结算时冻结的排行榜快照,用于后台查看和发奖依据留痕。 +type WeekStarRankSnapshot struct { + ID int64 `gorm:"column:id;primaryKey"` // 快照主键 + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_week_star_snapshot_rank,priority:1;index:idx_week_star_snapshot_cycle,priority:1"` // 活动配置 ID + CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_week_star_snapshot_rank,priority:2;index:idx_week_star_snapshot_cycle,priority:2"` // 周期标识 + Rank int `gorm:"column:rank;uniqueIndex:uk_week_star_snapshot_rank,priority:3"` // 结算名次 + PeriodStartAt time.Time `gorm:"column:period_start_at"` // 周期开始时间 + PeriodEndAt time.Time `gorm:"column:period_end_at"` // 周期结束时间 + UserID int64 `gorm:"column:user_id"` // 上榜用户 ID + Account string `gorm:"column:account;size:64"` // 上榜用户账号 + UserAvatar string `gorm:"column:user_avatar;size:1024"` // 上榜用户头像 + UserNickname string `gorm:"column:user_nickname;size:255"` // 上榜用户昵称 + CountryCode string `gorm:"column:country_code;size:32"` // 国家编码 + CountryName string `gorm:"column:country_name;size:128"` // 国家名称 + ScoreGold int64 `gorm:"column:score_gold"` // 结算金币分值 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜快照表名。 +func (WeekStarRankSnapshot) TableName() string { return "week_star_rank_snapshot" } + +// WeekStarRewardRecord 记录每个周期每个奖励名次的发奖状态,便于重试和审计。 +type WeekStarRewardRecord struct { + ID int64 `gorm:"column:id;primaryKey"` // 发奖记录主键 + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_week_star_reward_record_rank,priority:1;index:idx_week_star_reward_record_cycle,priority:1"` // 活动配置 ID + CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_week_star_reward_record_rank,priority:2;index:idx_week_star_reward_record_cycle,priority:2"` // 周期标识 + Rank int `gorm:"column:rank;uniqueIndex:uk_week_star_reward_record_rank,priority:3"` // 发奖名次 + UserID int64 `gorm:"column:user_id;index:idx_week_star_reward_record_user"` // 收奖用户 ID + Account string `gorm:"column:account;size:64"` // 收奖用户账号 + UserAvatar string `gorm:"column:user_avatar;size:1024"` // 收奖用户头像 + UserNickname string `gorm:"column:user_nickname;size:255"` // 收奖用户昵称 + CountryCode string `gorm:"column:country_code;size:32"` // 国家编码 + CountryName string `gorm:"column:country_name;size:128"` // 国家名称 + RewardGroupID int64 `gorm:"column:reward_group_id"` // 奖励组 ID + RewardGroupName string `gorm:"column:reward_group_name;size:255"` // 奖励组名称 + ScoreGold int64 `gorm:"column:score_gold"` // 该用户本周期最终积分 + TrackID int64 `gorm:"column:track_id"` // 发奖请求幂等追踪 ID + Status string `gorm:"column:status;size:32;index:idx_week_star_reward_record_status"` // 发奖状态 + RetryCount int `gorm:"column:retry_count"` // 已重试次数 + LastError string `gorm:"column:last_error;size:1024"` // 最近一次失败原因 + SentAt *time.Time `gorm:"column:sent_at"` // 实际发奖时间 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回周榜发奖记录表名。 +func (WeekStarRewardRecord) TableName() string { return "week_star_reward_record" } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 4072bf5..8ba991e 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -4,7 +4,6 @@ import ( "context" "chatapp3-golang/internal/config" - "chatapp3-golang/internal/model" "github.com/redis/go-redis/v9" "gorm.io/driver/mysql" @@ -17,15 +16,15 @@ type Repository struct { } func New(cfg config.Config) (*Repository, error) { - db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{}) + db, err := gorm.Open(mysql.Open(cfg.Store.MySQLDSN), &gorm.Config{}) if err != nil { return nil, err } client := redis.NewClient(&redis.Options{ - Addr: cfg.RedisAddr, - Password: cfg.RedisPassword, - DB: cfg.RedisDB, + Addr: cfg.Store.RedisAddr, + Password: cfg.Store.RedisPassword, + DB: cfg.Store.RedisDB, }) return &Repository{DB: db, Redis: client}, nil @@ -41,29 +40,3 @@ func (r *Repository) Ping(ctx context.Context) error { } return sqlDB.PingContext(ctx) } - -func (r *Repository) AutoMigrate() error { - if err := r.DB.AutoMigrate( - &model.ResidentRegisterRewardConfig{}, - &model.ResidentRegisterRewardLog{}, - &model.InviteCampaignConfig{}, - &model.InviteCampaignRewardRule{}, - &model.InviteCampaignRelation{}, - &model.InviteCampaignInviteeProgress{}, - &model.InviteCampaignMonthlyProgress{}, - &model.InviteCampaignTaskClaim{}, - &model.InviteCampaignRewardLog{}, - &model.InviteCampaignRechargeEvent{}, - &model.SysGameListVendorExt{}, - &model.BaishunGameCatalog{}, - &model.BaishunLaunchSession{}, - &model.BaishunOrderIdempotency{}, - &model.BaishunCallbackLog{}, - &model.BaishunRoomState{}, - ); err != nil { - return err - } - - // Keep callback error details queryable even when downstream returns a long body. - return r.DB.Exec("ALTER TABLE baishun_callback_log MODIFY COLUMN biz_message TEXT NULL").Error -} diff --git a/internal/http/baishun_handler.go b/internal/router/baishun_routes.go similarity index 74% rename from internal/http/baishun_handler.go rename to internal/router/baishun_routes.go index b5bdd4c..c9c8914 100644 --- a/internal/http/baishun_handler.go +++ b/internal/router/baishun_routes.go @@ -1,29 +1,33 @@ -package http +package router import ( - "chatapp3-golang/internal/config" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/service" "encoding/json" "fmt" "net/http" "strconv" "strings" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/baishun" + "github.com/gin-gonic/gin" ) +// registerBaishunRoutes 注册百顺游戏相关路由。 func registerBaishunRoutes( - router *gin.Engine, + engine *gin.Engine, cfg config.Config, - javaClient *integration.Client, - baishunService *service.BaishunService, + javaClient authGateway, + baishunService *baishun.BaishunService, ) { - appGroup := router.Group("/app/game") + if baishunService == nil { + return + } + + appGroup := engine.Group("/app/game") appGroup.Use(authMiddleware(javaClient)) appGroup.GET("/room/shortcut", func(c *gin.Context) { - user := mustAuthUser(c) - resp, err := baishunService.ListShortcutGames(c.Request.Context(), user, c.Query("roomId")) + resp, err := baishunService.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) if err != nil { writeError(c, err) return @@ -31,8 +35,7 @@ func registerBaishunRoutes( writeOK(c, resp) }) appGroup.GET("/room/list", func(c *gin.Context) { - user := mustAuthUser(c) - resp, err := baishunService.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category")) + resp, err := baishunService.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) if err != nil { writeError(c, err) return @@ -40,8 +43,7 @@ func registerBaishunRoutes( writeOK(c, resp) }) appGroup.GET("/baishun/state", func(c *gin.Context) { - user := mustAuthUser(c) - resp, err := baishunService.GetRoomState(c.Request.Context(), user, c.Query("roomId")) + resp, err := baishunService.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) if err != nil { writeError(c, err) return @@ -49,13 +51,12 @@ func registerBaishunRoutes( writeOK(c, resp) }) appGroup.POST("/baishun/launch", func(c *gin.Context) { - user := mustAuthUser(c) - var req service.BaishunLaunchAppRequest + var req baishun.BaishunLaunchAppRequest if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := baishunService.LaunchGame(c.Request.Context(), user, req, resolveClientIP(c)) + resp, err := baishunService.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c)) if err != nil { writeError(c, err) return @@ -63,13 +64,12 @@ func registerBaishunRoutes( writeOK(c, resp) }) appGroup.POST("/baishun/close", func(c *gin.Context) { - user := mustAuthUser(c) - var req service.BaishunCloseAppRequest + var req baishun.BaishunCloseAppRequest if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := baishunService.CloseGame(c.Request.Context(), user, req) + resp, err := baishunService.CloseGame(c.Request.Context(), mustAuthUser(c), req) if err != nil { writeError(c, err) return @@ -77,12 +77,12 @@ func registerBaishunRoutes( writeOK(c, resp) }) - internalGroup := router.Group("/internal/game/baishun") - internalGroup.Use(internalSecretMiddleware(cfg.InternalCallbackSecret)) + internalGroup := engine.Group("/internal/game/baishun") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) internalGroup.POST("/sync-catalog", func(c *gin.Context) { - var req service.SyncCatalogRequest + var req baishun.SyncCatalogRequest if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } resp, err := baishunService.SyncCatalog(c.Request.Context(), req) @@ -98,7 +98,7 @@ func registerBaishunRoutes( RoomID string `json:"roomId"` } if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } resp, err := baishunService.RefreshRoomState(c.Request.Context(), req.SysOrigin, req.RoomID) @@ -109,7 +109,7 @@ func registerBaishunRoutes( writeOK(c, resp) }) - callbackGroup := router.Group("/game/baishun") + callbackGroup := engine.Group("/game/baishun") callbackGroup.POST("/token", func(c *gin.Context) { raw, payload := readRawPayload(c) writeBaishunJSON(c, http.StatusOK, baishunService.HandleToken(c.Request.Context(), mapToTokenRequest(payload), raw)) @@ -136,26 +136,12 @@ func registerBaishunRoutes( }) } -func internalSecretMiddleware(secret string) gin.HandlerFunc { - return func(c *gin.Context) { - if strings.TrimSpace(secret) == "" { - c.Next() - return - } - token := strings.TrimSpace(c.GetHeader("X-Internal-Token")) - if token != secret { - writeError(c, service.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid")) - c.Abort() - return - } - c.Next() - } -} - +// writeBaishunJSON 输出百顺回调约定的 JSON 结构。 func writeBaishunJSON(c *gin.Context, status int, payload any) { c.JSON(status, payload) } +// readRawPayload 同时返回原始报文和 map 结构,方便百顺回调复用。 func readRawPayload(c *gin.Context) (string, map[string]any) { rawBytes, _ := c.GetRawData() raw := string(rawBytes) @@ -166,8 +152,9 @@ func readRawPayload(c *gin.Context) (string, map[string]any) { return raw, payload } -func mapToTokenRequest(payload map[string]any) service.BaishunTokenRequest { - return service.BaishunTokenRequest{ +// mapToTokenRequest 把百顺 token 回调报文映射成服务层 DTO。 +func mapToTokenRequest(payload map[string]any) baishun.BaishunTokenRequest { + return baishun.BaishunTokenRequest{ AppID: asInt64(payload["app_id"]), UserID: asString(payload["user_id"]), Code: asString(payload["code"]), @@ -177,8 +164,9 @@ func mapToTokenRequest(payload map[string]any) service.BaishunTokenRequest { } } -func mapToProfileRequest(payload map[string]any) service.BaishunProfileRequest { - return service.BaishunProfileRequest{ +// mapToProfileRequest 把百顺 profile 回调报文映射成服务层 DTO。 +func mapToProfileRequest(payload map[string]any) baishun.BaishunProfileRequest { + return baishun.BaishunProfileRequest{ AppID: asInt64(payload["app_id"]), UserID: asString(payload["user_id"]), SSToken: asString(payload["ss_token"]), @@ -190,8 +178,9 @@ func mapToProfileRequest(payload map[string]any) service.BaishunProfileRequest { } } -func mapToUpdateTokenRequest(payload map[string]any) service.BaishunUpdateTokenRequest { - return service.BaishunUpdateTokenRequest{ +// mapToUpdateTokenRequest 把百顺 update-token 回调报文映射成服务层 DTO。 +func mapToUpdateTokenRequest(payload map[string]any) baishun.BaishunUpdateTokenRequest { + return baishun.BaishunUpdateTokenRequest{ AppID: asInt64(payload["app_id"]), UserID: asString(payload["user_id"]), SSToken: asString(payload["ss_token"]), @@ -202,13 +191,14 @@ func mapToUpdateTokenRequest(payload map[string]any) service.BaishunUpdateTokenR } } -func mapToChangeBalanceRequest(payload map[string]any) service.BaishunChangeBalanceRequest { +// mapToChangeBalanceRequest 把百顺 change-balance 回调报文映射成服务层 DTO。 +func mapToChangeBalanceRequest(payload map[string]any) baishun.BaishunChangeBalanceRequest { var currencyType *int if rawValue, exists := payload["currency_type"]; exists && rawValue != nil { parsed := int(asInt64(rawValue)) currencyType = &parsed } - return service.BaishunChangeBalanceRequest{ + return baishun.BaishunChangeBalanceRequest{ AppID: asInt64(payload["app_id"]), UserID: asString(payload["user_id"]), SSToken: asString(payload["ss_token"]), @@ -228,8 +218,9 @@ func mapToChangeBalanceRequest(payload map[string]any) service.BaishunChangeBala } } -func mapToBalanceInfoRequest(payload map[string]any) service.BaishunBalanceInfoRequest { - return service.BaishunBalanceInfoRequest{ +// mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。 +func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest { + return baishun.BaishunBalanceInfoRequest{ UserID: asString(payload["user_id"]), AppID: asInt64(payload["app_id"]), AppChannel: asString(payload["app_channel"]), @@ -239,6 +230,7 @@ func mapToBalanceInfoRequest(payload map[string]any) service.BaishunBalanceInfoR } } +// asString 宽松读取字符串字段。 func asString(value any) string { if value == nil { return "" @@ -253,6 +245,7 @@ func asString(value any) string { } } +// asInt64 宽松读取数值字段。 func asInt64(value any) int64 { if value == nil { return 0 diff --git a/internal/router/invite_routes.go b/internal/router/invite_routes.go new file mode 100644 index 0000000..a3484ee --- /dev/null +++ b/internal/router/invite_routes.go @@ -0,0 +1,77 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/service/invite" + + "github.com/gin-gonic/gin" +) + +// registerInviteRoutes 注册邀请活动相关路由。 +func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteService *invite.InviteService) { + if inviteService == nil { + return + } + + engine.GET("/public/h5/invite-campaign/landing", func(c *gin.Context) { + resp, err := inviteService.GetLanding(c.Request.Context(), c.Query("code")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + internalGroup := engine.Group("/internal/invite-campaign") + internalGroup.POST("/recharge-success", func(c *gin.Context) { + var req invite.RechargeSuccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.HandleRechargeSuccess(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + appGroup := engine.Group("/app/h5/invite-campaign") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/home", func(c *gin.Context) { + resp, err := inviteService.GetHome(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/bind-code", func(c *gin.Context) { + var req invite.BindCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/tasks/claim", func(c *gin.Context) { + var req invite.TaskClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.ClaimTask(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/lucky_gift_routes.go b/internal/router/lucky_gift_routes.go new file mode 100644 index 0000000..64af459 --- /dev/null +++ b/internal/router/lucky_gift_routes.go @@ -0,0 +1,33 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/luckygift" + + "github.com/gin-gonic/gin" +) + +// registerLuckyGiftRoutes 注册幸运礼物内部路由。 +func registerLuckyGiftRoutes(engine *gin.Engine, cfg config.Config, luckyGiftService *luckygift.LuckyGiftService) { + if luckyGiftService == nil { + return + } + + internalGroup := engine.Group("/internal/lucky-gift") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) + internalGroup.POST("/draw", func(c *gin.Context) { + var req luckygift.LuckyGiftDrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, luckygift.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := luckyGiftService.Draw(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/middleware.go b/internal/router/middleware.go new file mode 100644 index 0000000..b10ed76 --- /dev/null +++ b/internal/router/middleware.go @@ -0,0 +1,122 @@ +package router + +import ( + "context" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/integration" + + "github.com/gin-gonic/gin" +) + +const authUserContextKey = "auth_user" + +type authGateway interface { + AuthenticateToken(ctx context.Context, token string) (integration.UserCredential, error) + AuthenticateConsoleToken(ctx context.Context, authorization string) (integration.ConsoleAccount, error) +} + +// authMiddleware 校验用户 token,并把认证后的用户信息写入上下文。 +func authMiddleware(javaClient authGateway) gin.HandlerFunc { + return func(c *gin.Context) { + authorization := strings.TrimSpace(c.GetHeader("Authorization")) + if authorization == "" { + writeError(c, common.NewAppError(http.StatusUnauthorized, "missing_authorization", "authorization is required")) + c.Abort() + return + } + + token := trimBearer(authorization) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + credential, err := javaClient.AuthenticateToken(ctx, token) + if err != nil { + writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_token", err.Error())) + c.Abort() + return + } + + c.Set(authUserContextKey, common.AuthUser{ + UserID: int64(credential.UserID), + SysOrigin: credential.SysOrigin, + Token: token, + Authorization: authorization, + }) + c.Next() + } +} + +// consoleAuthMiddleware 校验后台 console token,供后台管理类 Go 路由使用。 +func consoleAuthMiddleware(javaClient authGateway) gin.HandlerFunc { + return func(c *gin.Context) { + authorization := strings.TrimSpace(c.GetHeader("Authorization")) + if authorization == "" { + writeError(c, common.NewAppError(http.StatusUnauthorized, "missing_authorization", "authorization is required")) + c.Abort() + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + if _, err := javaClient.AuthenticateConsoleToken(ctx, authorization); err != nil { + writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_console_token", err.Error())) + c.Abort() + return + } + + c.Next() + } +} + +// internalSecretMiddleware 校验内部回调密钥。 +func internalSecretMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + if strings.TrimSpace(secret) == "" { + c.Next() + return + } + + token := strings.TrimSpace(c.GetHeader("X-Internal-Token")) + if token != secret { + writeError(c, common.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid")) + c.Abort() + return + } + + c.Next() + } +} + +// mustAuthUser 从 Gin 上下文读取已经通过鉴权的用户。 +func mustAuthUser(c *gin.Context) common.AuthUser { + value, exists := c.Get(authUserContextKey) + if !exists { + return common.AuthUser{} + } + user, _ := value.(common.AuthUser) + return user +} + +// trimBearer 去掉 Authorization 里的 Bearer 前缀。 +func trimBearer(authorization string) string { + if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { + return strings.TrimSpace(authorization[7:]) + } + return authorization +} + +// resolveClientIP 解析请求来源 IP。 +func resolveClientIP(c *gin.Context) string { + if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + return strings.TrimSpace(c.ClientIP()) +} diff --git a/internal/router/register_reward_routes.go b/internal/router/register_reward_routes.go new file mode 100644 index 0000000..1bc810b --- /dev/null +++ b/internal/router/register_reward_routes.go @@ -0,0 +1,110 @@ +package router + +import ( + "chatapp3-golang/internal/service/registerreward" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// registerRegisterRewardRoutes 注册注册奖励联调与后台路由。 +func registerRegisterRewardRoutes( + engine *gin.Engine, + javaClient authGateway, + registerRewardService *registerreward.RegisterRewardService, +) { + if registerRewardService == nil { + return + } + + appGroup := engine.Group("/app/register-reward") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/status", func(c *gin.Context) { + resp, err := registerRewardService.GetStatus(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/content", func(c *gin.Context) { + resp, err := registerRewardService.GetContent(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + debugGroup := engine.Group("/debug/register-reward") + debugGroup.Use(authMiddleware(javaClient)) + debugGroup.GET("/notice/stream", func(c *gin.Context) { + user := mustAuthUser(c) + stream, cancel := registerRewardService.SubscribeDebugNotices(user.UserID) + defer cancel() + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + writeError(c, registerreward.NewAppError(http.StatusInternalServerError, "sse_not_supported", "streaming is not supported")) + return + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + fmt.Fprint(c.Writer, ": connected\n\n") + flusher.Flush() + + heartbeat := time.NewTicker(20 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-c.Request.Context().Done(): + return + case <-heartbeat.C: + fmt.Fprint(c.Writer, ": ping\n\n") + flusher.Flush() + case event, ok := <-stream: + if !ok { + return + } + payload, err := json.Marshal(event) + if err != nil { + writeError(c, err) + return + } + fmt.Fprintf(c.Writer, "event: register-reward\n") + fmt.Fprintf(c.Writer, "data: %s\n\n", payload) + flusher.Flush() + } + } + }) + + residentGroup := engine.Group("/resident-activity/register-reward") + residentGroup.Use(consoleAuthMiddleware(javaClient)) + residentGroup.GET("/daily-record/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := registerRewardService.PageDailyRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("claimDate"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/response.go b/internal/router/response.go new file mode 100644 index 0000000..64fa5bf --- /dev/null +++ b/internal/router/response.go @@ -0,0 +1,56 @@ +package router + +import ( + "errors" + "net/http" + "time" + + "chatapp3-golang/internal/common" + + "github.com/gin-gonic/gin" +) + +// writeOK 输出统一成功响应。 +func writeOK(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "status": true, + "errorCode": 0, + "errorMsg": "success", + "body": data, + "code": "success", + "message": "success", + "data": data, + "time": time.Now().UnixMilli(), + }) +} + +// writeError 输出统一错误响应。 +func writeError(c *gin.Context, err error) { + var appErr *common.AppError + if errors.As(err, &appErr) { + c.JSON(appErr.Status, gin.H{ + "status": false, + "errorCode": appErr.Status, + "errorCodeName": appErr.Code, + "errorMsg": appErr.Message, + "body": nil, + "code": appErr.Code, + "message": appErr.Message, + "data": nil, + "time": time.Now().UnixMilli(), + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "status": false, + "errorCode": http.StatusInternalServerError, + "errorCodeName": "internal_error", + "errorMsg": err.Error(), + "body": nil, + "code": "internal_error", + "message": err.Error(), + "data": nil, + "time": time.Now().UnixMilli(), + }) +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..29ccf99 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,63 @@ +package router + +import ( + "context" + "net/http" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/invite" + "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/weekstar" + + "github.com/gin-gonic/gin" +) + +// Services 聚合所有业务模块服务,供路由层统一装配。 +type Services struct { + Invite *invite.InviteService + Baishun *baishun.BaishunService + LuckyGift *luckygift.LuckyGiftService + RegisterReward *registerreward.RegisterRewardService + WeekStar *weekstar.WeekStarService +} + +// NewRouter 创建 Gin 路由,并按模块注册所有接口。 +func NewRouter( + cfg config.Config, + repository *repo.Repository, + javaClient authGateway, + services Services, +) *gin.Engine { + engine := gin.New() + engine.Use(gin.Logger(), gin.Recovery()) + + registerHealthRoutes(engine, cfg, repository) + registerInviteRoutes(engine, javaClient, services.Invite) + registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) + registerWeekStarRoutes(engine, javaClient, services.WeekStar) + registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) + registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) + + return engine +} + +// registerHealthRoutes 注册健康检查接口。 +func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *repo.Repository) { + engine.GET("/health", func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.HTTP.Timeout) + defer cancel() + + if err := repository.Ping(ctx); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "code": "dependency_unavailable", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success2"}) + }) +} diff --git a/internal/router/week_star_routes.go b/internal/router/week_star_routes.go new file mode 100644 index 0000000..4c26255 --- /dev/null +++ b/internal/router/week_star_routes.go @@ -0,0 +1,108 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/weekstar" + + "github.com/gin-gonic/gin" +) + +// registerWeekStarRoutes 注册指定礼物周榜路由。 +func registerWeekStarRoutes(engine *gin.Engine, javaClient authGateway, weekStarService *weekstar.WeekStarService) { + if weekStarService == nil { + return + } + + engine.GET("/public/h5/week-star/page", func(c *gin.Context) { + resp, err := weekStarService.GetPublicPage(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + registerSpecifiedGiftWeeklyRankRoutes(engine.Group("/resident-activity/specified-gift-weekly-rank"), javaClient, weekStarService) + registerSpecifiedGiftWeeklyRankRoutes(engine.Group("/resident-activity/week-star"), javaClient, weekStarService) +} + +// registerSpecifiedGiftWeeklyRankRoutes 注册周榜后台配置、快照和发奖记录路由。 +func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient authGateway, weekStarService *weekstar.WeekStarService) { + group.Use(consoleAuthMiddleware(javaClient)) + + group.GET("/config/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := weekStarService.PageConfigs(c.Request.Context(), c.Query("sysOrigin"), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/config", func(c *gin.Context) { + id, _ := strconv.ParseInt(strings.TrimSpace(c.Query("id")), 10, 64) + resp, err := weekStarService.GetConfig(c.Request.Context(), c.Query("sysOrigin"), id) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.POST("/config/save", func(c *gin.Context) { + var req weekstar.SaveWeekStarConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := weekStarService.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/snapshot/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + configID, _ := strconv.ParseInt(strings.TrimSpace(c.Query("configId")), 10, 64) + resp, err := weekStarService.PageSnapshots(c.Request.Context(), c.Query("sysOrigin"), configID, c.Query("cycleKey"), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/reward-record/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + configID, _ := strconv.ParseInt(strings.TrimSpace(c.Query("configId")), 10, 64) + resp, err := weekStarService.PageRewardRecords(c.Request.Context(), c.Query("sysOrigin"), configID, c.Query("cycleKey"), c.Query("status"), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.POST("/reward-record/retry", func(c *gin.Context) { + var req weekstar.WeekStarRetryRewardRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/service/baishun.go b/internal/service/baishun.go deleted file mode 100644 index 5397e44..0000000 --- a/internal/service/baishun.go +++ /dev/null @@ -1,1434 +0,0 @@ -package service - -import ( - "bytes" - "chatapp3-golang/internal/config" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/model" - "chatapp3-golang/internal/repo" - "chatapp3-golang/internal/util" - "context" - "crypto/md5" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - "time" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -const ( - baishunVendorType = "BAISHUN" - baishunLaunchModeRemote = "H5_REMOTE" - baishunLaunchModeLocalZip = "H5_ZIP_LOCAL" - baishunGameListTypeGame = 2 - baishunCatalogEnabled = "ENABLED" - baishunRoomStateIdle = "IDLE" - baishunRoomStatePlaying = "PLAYING" - baishunSessionInit = "INIT" - baishunSessionCodeIssued = "CODE_ISSUED" - baishunSessionTokenIssued = "TOKEN_ISSUED" - baishunSessionActive = "ACTIVE" - baishunSessionClosed = "CLOSED" - baishunSessionExpired = "EXPIRED" - baishunOrderStatusInit = "INIT" - baishunOrderStatusSuccess = "SUCCESS" - baishunOrderStatusFailed = "FAILED" - baishunCallbackStatusSuccess = "SUCCESS" - baishunCallbackStatusFailed = "FAILED" - baishunCodeInsufficientAssets = 1008 - baishunCodeTokenExpired = 1009 - baishunCodeSignatureError = 1010 - baishunCodeRepeatUnknown = 1011 - baishunCodeServerError = 1999 - legacyBizMessageRuneLimit = 255 -) - -type BaishunService struct { - cfg config.Config - repo *repo.Repository - java *integration.Client - httpClient *http.Client -} - -func NewBaishunService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *BaishunService { - return &BaishunService{ - cfg: cfg, - repo: repository, - java: javaClient, - httpClient: &http.Client{ - Timeout: cfg.Timeout, - }, - } -} - -type RoomGameListItem struct { - GameID string `json:"gameId"` - VendorType string `json:"vendorType"` - VendorGameID int `json:"vendorGameId"` - Name string `json:"name"` - Cover string `json:"cover"` - Category string `json:"category,omitempty"` - Sort int64 `json:"sort,omitempty"` - LaunchMode string `json:"launchMode"` - FullScreen bool `json:"fullScreen"` - GameMode int `json:"gameMode"` - SafeHeight int `json:"safeHeight"` - Orientation int `json:"orientation"` - PackageVersion string `json:"packageVersion,omitempty"` - Status string `json:"status,omitempty"` -} - -type RoomGameListResponse struct { - Items []RoomGameListItem `json:"items"` -} - -type BaishunRoomStateResponse struct { - RoomID string `json:"roomId"` - State string `json:"state"` - GameSessionID string `json:"gameSessionId,omitempty"` - CurrentGameID string `json:"currentGameId,omitempty"` - CurrentVendorGameID int `json:"currentVendorGameId,omitempty"` - CurrentGameName string `json:"currentGameName,omitempty"` - CurrentGameCover string `json:"currentGameCover,omitempty"` - HostUserID int64 `json:"hostUserId,omitempty"` -} - -type BaishunLaunchAppRequest struct { - RoomID string `json:"roomId"` - GameID string `json:"gameId"` - SceneMode int `json:"sceneMode"` - ClientOrigin string `json:"clientOrigin"` -} - -type BaishunCloseAppRequest struct { - RoomID string `json:"roomId"` - GameSessionID string `json:"gameSessionId"` - Reason string `json:"reason"` -} - -type BaishunGameConfig struct { - SceneMode int `json:"sceneMode"` - CurrencyIcon string `json:"currencyIcon"` -} - -type BaishunBridgeConfig struct { - AppName string `json:"appName"` - AppChannel string `json:"appChannel"` - AppID int64 `json:"appId"` - UserID string `json:"userId"` - Code string `json:"code"` - RoomID string `json:"roomId"` - GameMode string `json:"gameMode"` - Language string `json:"language"` - GSP int `json:"gsp"` - GameConfig BaishunGameConfig `json:"gameConfig"` -} - -type BaishunLaunchEntry struct { - LaunchMode string `json:"launchMode"` - EntryURL string `json:"entryUrl"` - PreviewURL string `json:"previewUrl"` - DownloadURL string `json:"downloadUrl"` - PackageVersion string `json:"packageVersion"` - Orientation int `json:"orientation"` - SafeHeight int `json:"safeHeight"` -} - -type BaishunLaunchAppResponse struct { - GameSessionID string `json:"gameSessionId"` - VendorType string `json:"vendorType"` - GameID string `json:"gameId"` - VendorGameID int `json:"vendorGameId"` - Entry BaishunLaunchEntry `json:"entry"` - BridgeConfig BaishunBridgeConfig `json:"bridgeConfig"` - RoomState BaishunRoomStateResponse `json:"roomState"` -} - -type SyncCatalogRequest struct { - SysOrigin string `json:"sysOrigin"` - VendorGameIDs []int `json:"vendorGameIds"` - Force bool `json:"force"` -} - -type SyncCatalogResponse struct { - Inserted int `json:"inserted"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` -} - -type BaishunTokenRequest struct { - AppID int64 `json:"app_id"` - UserID string `json:"user_id"` - Code string `json:"code"` - Signature string `json:"signature"` - SignatureNonce string `json:"signature_nonce"` - Timestamp int64 `json:"timestamp"` -} - -type BaishunProfileRequest struct { - AppID int64 `json:"app_id"` - UserID string `json:"user_id"` - SSToken string `json:"ss_token"` - ClientIP string `json:"client_ip"` - GameID int `json:"game_id"` - Signature string `json:"signature"` - SignatureNonce string `json:"signature_nonce"` - Timestamp int64 `json:"timestamp"` -} - -type BaishunUpdateTokenRequest struct { - AppID int64 `json:"app_id"` - UserID string `json:"user_id"` - SSToken string `json:"ss_token"` - GameID int `json:"game_id"` - Signature string `json:"signature"` - SignatureNonce string `json:"signature_nonce"` - Timestamp int64 `json:"timestamp"` -} - -type BaishunChangeBalanceRequest struct { - AppID int64 `json:"app_id"` - UserID string `json:"user_id"` - SSToken string `json:"ss_token"` - CurrencyDiff int64 `json:"currency_diff"` - DiffMsg string `json:"diff_msg"` - GameID int `json:"game_id"` - GameRoundID string `json:"game_round_id"` - RoomID string `json:"room_id"` - ChangeTimeAt int64 `json:"change_time_at"` - OrderID string `json:"order_id"` - Extend string `json:"extend"` - MsgType string `json:"msg_type"` - CurrencyType *int `json:"currency_type"` - Signature string `json:"signature"` - SignatureNonce string `json:"signature_nonce"` - Timestamp int64 `json:"timestamp"` -} - -type BaishunBalanceInfoRequest struct { - UserID string `json:"user_id"` - AppID int64 `json:"app_id"` - AppChannel string `json:"app_channel"` - Signature string `json:"signature"` - SignatureNonce string `json:"signature_nonce"` - Timestamp int64 `json:"timestamp"` -} - -type baishunStandardResponse struct { - Code int `json:"code"` - Message string `json:"message"` - UniqueID string `json:"unique_id,omitempty"` - Data interface{} `json:"data,omitempty"` - UserInfo interface{} `json:"user_info,omitempty"` -} - -type baishunBalanceInfoResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data interface{} `json:"data,omitempty"` -} - -type gameListRow struct { - ConfigID int64 - SysOrigin string - InternalGameID string - Name string - Category string - Cover string - Sort int64 - FullScreen bool - GameModeRaw string - VendorType string - VendorGameID string - LaunchMode string - ExtPackageVersion string - ExtPreviewURL string - PackageURL string - ExtOrientation *int - ExtSafeHeight int - CurrencyIcon string - ExtGSP string - CatalogName string - CatalogCover string - CatalogPreviewURL string - CatalogDownloadURL string - CatalogPackageVersion string - CatalogOrientation *int - CatalogSafeHeight int - CatalogStatus string -} - -type baishunPlatformResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data []baishunPlatformGame `json:"data"` -} - -type baishunPlatformOneGameResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data baishunPlatformGame `json:"data"` -} - -type baishunPlatformGame struct { - GameID int `json:"game_id"` - Name string `json:"name"` - PreviewURL string `json:"preview_url"` - GameVersion string `json:"game_version"` - DownloadURL string `json:"download_url"` - GameMode []int `json:"game_mode"` - GameOrientation int `json:"game_orientation"` - SafeHeight int `json:"safe_height"` - VenueLevel []int `json:"venue_level"` -} - -func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { - items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "") - if err != nil { - return nil, err - } - if len(items) > 5 { - items = items[:5] - } - return items, nil -} - -func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { - items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category) - if err != nil { - return nil, err - } - return &RoomGameListResponse{Items: items}, nil -} - -func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID string) (*BaishunRoomStateResponse, error) { - var state model.BaishunRoomState - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND room_id = ?", user.SysOrigin, roomID). - First(&state).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil - } - return nil, err - } - resp := &BaishunRoomStateResponse{ - RoomID: roomID, - State: defaultIfBlank(state.State, baishunRoomStateIdle), - GameSessionID: state.GameSessionID, - CurrentGameID: state.CurrentGameID, - CurrentGameName: state.CurrentGameName, - CurrentGameCover: state.CurrentGameCover, - HostUserID: state.HostUserID, - } - if state.CurrentVendorGameID != nil { - resp.CurrentVendorGameID = *state.CurrentVendorGameID - } - return resp, nil -} - -func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req BaishunLaunchAppRequest, clientIP string) (*BaishunLaunchAppResponse, error) { - if strings.TrimSpace(req.RoomID) == "" { - return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") - } - if strings.TrimSpace(req.GameID) == "" { - return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "gameId is required") - } - - row, err := s.findGameRow(ctx, user.SysOrigin, req.GameID) - if err != nil { - return nil, err - } - - sessionID, err := util.NextID() - if err != nil { - return nil, err - } - sessionDBID, err := util.NextID() - if err != nil { - return nil, err - } - roomStateID, err := util.NextID() - if err != nil { - return nil, err - } - launchCode, err := s.randomToken(24) - if err != nil { - return nil, err - } - - now := time.Now() - session := model.BaishunLaunchSession{ - ID: sessionDBID, - SysOrigin: user.SysOrigin, - RoomID: req.RoomID, - UserID: user.UserID, - InternalGameID: row.InternalGameID, - VendorGameID: row.vendorGameIDInt(), - GameSessionID: fmt.Sprintf("bs_%s_%d", sanitizeRoomID(req.RoomID), sessionID), - LaunchCode: launchCode, - LaunchCodeExpireTime: now.Add(time.Duration(s.cfg.BaishunLaunchCodeTTLSeconds) * time.Second), - Language: s.resolveLanguage(ctx, user.UserID), - GSP: s.resolveGSP(row), - CurrencyType: 0, - CurrencyIcon: row.currencyIcon(), - SceneMode: req.SceneMode, - GameMode: row.gameMode(), - PackageVersion: row.packageVersion(), - ClientIP: clientIP, - ClientOrigin: defaultIfBlank(req.ClientOrigin, "COMMON"), - Status: baishunSessionCodeIssued, - CreateTime: now, - UpdateTime: now, - } - - roomState := model.BaishunRoomState{ - ID: roomStateID, - SysOrigin: user.SysOrigin, - RoomID: req.RoomID, - HostUserID: user.UserID, - CurrentGameID: row.InternalGameID, - CurrentGameName: row.name(), - CurrentGameCover: row.cover(), - GameSessionID: session.GameSessionID, - LaunchUserID: &user.UserID, - State: baishunRoomStatePlaying, - StartTime: &now, - CreateTime: now, - UpdateTime: now, - } - vendorGameID := row.vendorGameIDInt() - roomState.CurrentVendorGameID = &vendorGameID - - if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&session).Error; err != nil { - return err - } - return tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sys_origin"}, {Name: "room_id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "host_user_id", "current_game_id", "current_vendor_game_id", "current_game_name", - "current_game_cover", "game_session_id", "launch_user_id", "state", "start_time", - "end_time", "update_time", - }), - }).Create(&roomState).Error - }); err != nil { - return nil, err - } - - return &BaishunLaunchAppResponse{ - GameSessionID: session.GameSessionID, - VendorType: baishunVendorType, - GameID: row.InternalGameID, - VendorGameID: row.vendorGameIDInt(), - Entry: BaishunLaunchEntry{ - LaunchMode: row.launchMode(), - EntryURL: row.entryURL(), - PreviewURL: row.previewURL(), - DownloadURL: row.downloadURL(), - PackageVersion: row.packageVersion(), - Orientation: row.orientation(), - SafeHeight: row.safeHeight(), - }, - BridgeConfig: BaishunBridgeConfig{ - AppName: s.cfg.BaishunAppName, - AppChannel: defaultIfBlank(s.cfg.BaishunAppChannel, "skychat"), - AppID: s.cfg.BaishunAppID, - UserID: strconv.FormatInt(user.UserID, 10), - Code: launchCode, - RoomID: req.RoomID, - GameMode: strconv.Itoa(row.gameMode()), - Language: session.Language, - GSP: session.GSP, - GameConfig: BaishunGameConfig{ - SceneMode: req.SceneMode, - CurrencyIcon: session.CurrencyIcon, - }, - }, - RoomState: BaishunRoomStateResponse{ - RoomID: req.RoomID, - State: baishunRoomStatePlaying, - GameSessionID: session.GameSessionID, - CurrentGameID: row.InternalGameID, - CurrentVendorGameID: row.vendorGameIDInt(), - CurrentGameName: row.name(), - CurrentGameCover: row.cover(), - HostUserID: user.UserID, - }, - }, nil -} - -func (s *BaishunService) CloseGame(ctx context.Context, user AuthUser, req BaishunCloseAppRequest) (*BaishunRoomStateResponse, error) { - if strings.TrimSpace(req.RoomID) == "" { - return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") - } - now := time.Now() - if strings.TrimSpace(req.GameSessionID) != "" { - if err := s.repo.DB.WithContext(ctx). - Model(&model.BaishunLaunchSession{}). - Where("sys_origin = ? AND room_id = ? AND game_session_id = ?", user.SysOrigin, req.RoomID, req.GameSessionID). - Updates(map[string]any{ - "status": baishunSessionClosed, - "closed_reason": defaultIfBlank(req.Reason, "user_exit"), - "update_time": now, - }).Error; err != nil { - return nil, err - } - } - - if err := s.repo.DB.WithContext(ctx). - Model(&model.BaishunRoomState{}). - Where("sys_origin = ? AND room_id = ?", user.SysOrigin, req.RoomID). - Updates(map[string]any{ - "state": baishunRoomStateIdle, - "end_time": now, - "game_session_id": "", - "current_game_id": "", - "current_vendor_game_id": nil, - "current_game_name": "", - "current_game_cover": "", - "update_time": now, - }).Error; err != nil { - return nil, err - } - - return &BaishunRoomStateResponse{ - RoomID: req.RoomID, - State: baishunRoomStateIdle, - }, nil -} - -func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { - if strings.TrimSpace(s.cfg.BaishunPlatformBaseURL) == "" || s.cfg.BaishunAppID == 0 { - return nil, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun platform config is missing") - } - sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI") - if strings.TrimSpace(sysOrigin) == "" { - sysOrigin = "LIKEI" - } - - games, err := s.fetchPlatformGames(ctx) - if err != nil { - return nil, err - } - filter := make(map[int]struct{}, len(req.VendorGameIDs)) - for _, id := range req.VendorGameIDs { - filter[id] = struct{}{} - } - - resp := &SyncCatalogResponse{} - for _, game := range games { - if len(filter) > 0 { - if _, ok := filter[game.GameID]; !ok { - resp.Skipped++ - continue - } - } - id, err := util.NextID() - if err != nil { - return nil, err - } - record := model.BaishunGameCatalog{ - ID: id, - SysOrigin: sysOrigin, - InternalGameID: fmt.Sprintf("bs_%d", game.GameID), - VendorGameID: game.GameID, - Name: game.Name, - Cover: defaultIfBlank(game.PreviewURL, game.DownloadURL), - PreviewURL: game.PreviewURL, - DownloadURL: game.DownloadURL, - PackageVersion: game.GameVersion, - GameModeJSON: mustJSONString(game.GameMode), - Orientation: intPtr(game.GameOrientation), - SafeHeight: game.SafeHeight, - VenueLevelJSON: mustJSONString(game.VenueLevel), - GSP: strconv.Itoa(s.cfg.BaishunGSP), - RawJSON: mustJSONString(game), - Status: baishunCatalogEnabled, - CreateTime: time.Now(), - UpdateTime: time.Now(), - } - err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "internal_game_id", "name", "cover", "preview_url", "download_url", - "package_version", "game_mode_json", "orientation", "safe_height", - "venue_level_json", "gsp", "raw_json", "status", "update_time", - }), - }).Create(&record).Error - if err != nil { - return nil, err - } - if req.Force { - resp.Updated++ - } else { - resp.Inserted++ - } - } - return resp, nil -} - -func (s *BaishunService) RefreshRoomState(ctx context.Context, sysOrigin, roomID string) (*BaishunRoomStateResponse, error) { - var state model.BaishunRoomState - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND room_id = ?", sysOrigin, roomID). - First(&state).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil - } - return nil, err - } - if strings.TrimSpace(state.GameSessionID) == "" { - state.State = baishunRoomStateIdle - _ = s.repo.DB.WithContext(ctx).Model(&model.BaishunRoomState{}). - Where("id = ?", state.ID). - Update("state", baishunRoomStateIdle).Error - } - resp := &BaishunRoomStateResponse{ - RoomID: roomID, - State: defaultIfBlank(state.State, baishunRoomStateIdle), - GameSessionID: state.GameSessionID, - CurrentGameID: state.CurrentGameID, - CurrentGameName: state.CurrentGameName, - CurrentGameCover: state.CurrentGameCover, - HostUserID: state.HostUserID, - } - if state.CurrentVendorGameID != nil { - resp.CurrentVendorGameID = *state.CurrentVendorGameID - } - return resp, nil -} - -func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { - return s.failStandard(baishunCodeSignatureError, "signature error") - } - var session model.BaishunLaunchSession - if err := s.repo.DB.WithContext(ctx). - Where("launch_code = ?", strings.TrimSpace(req.Code)). - First(&session).Error; err != nil { - resp := s.failStandard(baishunCodeTokenExpired, "code invalid") - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - if session.Status != baishunSessionCodeIssued || time.Now().After(session.LaunchCodeExpireTime) { - resp := s.failStandard(baishunCodeTokenExpired, "code expired") - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - if strings.TrimSpace(req.UserID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(req.UserID) { - resp := s.failStandard(baishunCodeTokenExpired, "code invalid") - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - - ssToken, err := s.randomToken(32) - if err != nil { - resp := s.failStandard(baishunCodeServerError, "token create failed") - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second) - if err := s.repo.DB.WithContext(ctx). - Model(&model.BaishunLaunchSession{}). - Where("id = ?", session.ID). - Updates(map[string]any{ - "ss_token": ssToken, - "ss_token_expire_time": expire, - "status": baishunSessionTokenIssued, - "update_time": time.Now(), - }).Error; err != nil { - resp := s.failStandard(baishunCodeServerError, "session update failed") - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - - userInfo := s.readUserInfo(ctx, session.UserID) - balance := s.readUserBalance(ctx, session.UserID) - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{ - "ss_token": ssToken, - "expire_date": expire.UnixMilli(), - }, - UserInfo: map[string]any{ - "user_id": req.UserID, - "user_name": userInfo.UserNickname, - "user_avatar": userInfo.UserAvatar, - "balance": balance, - }, - } - s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - -func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { - return s.failStandard(baishunCodeSignatureError, "signature error") - } - session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) - if !ok { - resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") - s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - userInfo := s.readUserInfo(ctx, session.UserID) - balance := s.readUserBalance(ctx, session.UserID) - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{ - "user_id": req.UserID, - "user_name": userInfo.UserNickname, - "user_avatar": userInfo.UserAvatar, - "balance": balance, - "balance_list": []map[string]any{{"name": "Gold", "currency_type": 0, "currency_amount": balance}}, - "user_type": 1, - }, - } - _ = s.repo.DB.WithContext(ctx). - Model(&model.BaishunLaunchSession{}). - Where("id = ?", session.ID). - Updates(map[string]any{"status": baishunSessionActive, "update_time": time.Now()}).Error - s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - -func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { - return s.failStandard(baishunCodeSignatureError, "signature error") - } - session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) - if !ok { - resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") - s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - newToken, err := s.randomToken(32) - if err != nil { - resp := s.failStandard(baishunCodeServerError, "token create failed") - s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second) - if err := s.repo.DB.WithContext(ctx). - Model(&model.BaishunLaunchSession{}). - Where("id = ?", session.ID). - Updates(map[string]any{ - "ss_token": newToken, - "ss_token_expire_time": expire, - "status": baishunSessionActive, - "update_time": time.Now(), - }).Error; err != nil { - resp := s.failStandard(baishunCodeServerError, "session update failed") - s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{ - "ss_token": newToken, - "expire_date": expire.UnixMilli(), - }, - } - s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - -func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { - return s.failStandard(baishunCodeSignatureError, "signature error") - } - session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) - if !ok { - resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") - s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), "", req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - if req.CurrencyDiff == 0 { - resp := s.failStandard(baishunCodeServerError, "currency_diff invalid") - s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - - lockKey := fmt.Sprintf("bs:lock:user:%s:%s", session.SysOrigin, req.UserID) - lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", 5*time.Second).Result() - if err == nil && lockOK { - defer s.repo.Redis.Del(context.Background(), lockKey) - } - - var order model.BaishunOrderIdempotency - err = s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND order_id = ?", session.SysOrigin, req.OrderID). - First(&order).Error - if err == nil && order.Status == baishunOrderStatusSuccess && strings.TrimSpace(order.ResponseJSON) != "" { - var resp baishunStandardResponse - if json.Unmarshal([]byte(order.ResponseJSON), &resp) == nil { - s.saveCallbackLog(ctx, "change-balance", rawJSON, order.ResponseJSON, session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) - return resp - } - } - - walletEventID := fmt.Sprintf("BAISHUN:%s:%s", session.SysOrigin, req.OrderID) - exists, existsErr := s.java.ExistsGoldEvent(ctx, walletEventID) - if existsErr == nil && !exists { - changeErr := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ - ReceiptType: receiptTypeFromDiff(req.CurrencyDiff), - UserID: session.UserID, - SysOrigin: session.SysOrigin, - EventID: walletEventID, - Remark: defaultIfBlank(req.DiffMsg, req.MsgType), - Amount: integration.NewPennyAmountPayloadFromDollar(abs64(req.CurrencyDiff)), - CloseDelayAsset: false, - OpUserType: "APP", - CustomizeOrigin: fmt.Sprintf("BAISHUN_GAME_%d", req.GameID), - CustomizeOriginDesc: fmt.Sprintf("BAISHUN GAME[%d]", req.GameID), - }) - if changeErr != nil { - message := changeErr.Error() - code := baishunCodeServerError - if strings.Contains(strings.ToLower(message), "insufficient") || strings.Contains(message, "5000") { - code = baishunCodeInsufficientAssets - } - resp := s.failStandard(code, message) - s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusFailed, rawJSON, mustJSONString(resp), nil) - s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) - return resp - } - } - - balance := s.readUserBalance(ctx, session.UserID) - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{ - "currency_balance": balance, - }, - } - s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, mustJSONString(resp), nil) - s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - -func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse { - userID := toString(payload["user_id"]) - orderID := toString(payload["order_id"]) - gameRoundID := toString(payload["game_round_id"]) - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{}, - } - s.saveCallbackLog(ctx, "report", rawJSON, mustJSONString(resp), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - -func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse { - if !s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp) { - return baishunBalanceInfoResponse{Code: baishunCodeSignatureError, Msg: "signature error"} - } - userID, _ := strconv.ParseInt(req.UserID, 10, 64) - balance := s.readUserBalance(ctx, userID) - resp := baishunBalanceInfoResponse{ - Code: 0, - Msg: "success", - Data: map[string]any{"cur_coin": balance}, - } - s.saveCallbackLog(ctx, "balance-info", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Msg) - return resp -} - -func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { - var rows []gameListRow - query := s.repo.DB.WithContext(ctx). - Table("sys_game_list_config AS cfg"). - Select(` - cfg.id AS config_id, - cfg.sys_origin AS sys_origin, - cfg.game_id AS internal_game_id, - cfg.name AS name, - cfg.category AS category, - cfg.cover AS cover, - cfg.sort AS sort, - cfg.full_screen AS full_screen, - cfg.game_mode AS game_mode_raw, - ext.vendor_type AS vendor_type, - ext.vendor_game_id AS vendor_game_id, - ext.launch_mode AS launch_mode, - ext.package_version AS ext_package_version, - ext.preview_url AS ext_preview_url, - ext.package_url AS package_url, - ext.orientation AS ext_orientation, - ext.safe_height AS ext_safe_height, - ext.currency_icon AS currency_icon, - ext.gsp AS ext_gsp, - cat.name AS catalog_name, - cat.cover AS catalog_cover, - cat.preview_url AS catalog_preview_url, - cat.download_url AS catalog_download_url, - cat.package_version AS catalog_package_version, - cat.orientation AS catalog_orientation, - cat.safe_height AS catalog_safe_height, - cat.status AS catalog_status - `). - Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). - Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). - Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) - if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") { - query = query.Where("cfg.category = ?", category) - } - if strings.TrimSpace(roomID) != "" { - query = query.Where("cfg.game_mode LIKE ?", "%CHAT_ROOM%") - } - if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil { - return nil, err - } - - items := make([]RoomGameListItem, 0, len(rows)) - for _, row := range rows { - items = append(items, row.toListItem()) - } - if len(items) > 0 { - return items, nil - } - - var catalogs []model.BaishunGameCatalog - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled). - Order("vendor_game_id ASC"). - Find(&catalogs).Error; err != nil { - return nil, err - } - for _, catalog := range catalogs { - gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)) - items = append(items, RoomGameListItem{ - GameID: gameID, - VendorType: baishunVendorType, - VendorGameID: catalog.VendorGameID, - Name: catalog.Name, - Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), - Category: "CHAT_ROOM", - Sort: int64(catalog.VendorGameID), - LaunchMode: baishunLaunchModeRemote, - FullScreen: true, - GameMode: pickFirstInt(catalog.GameModeJSON, 3), - SafeHeight: catalog.SafeHeight, - Orientation: derefInt(catalog.Orientation), - PackageVersion: catalog.PackageVersion, - Status: catalog.Status, - }) - } - sort.Slice(items, func(i, j int) bool { - if items[i].Sort == items[j].Sort { - return items[i].VendorGameID < items[j].VendorGameID - } - return items[i].Sort > items[j].Sort - }) - return items, nil -} - -func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) { - var row gameListRow - err := s.repo.DB.WithContext(ctx). - Table("sys_game_list_config AS cfg"). - Select(` - cfg.id AS config_id, - cfg.sys_origin AS sys_origin, - cfg.game_id AS internal_game_id, - cfg.name AS name, - cfg.category AS category, - cfg.cover AS cover, - cfg.sort AS sort, - cfg.full_screen AS full_screen, - cfg.game_mode AS game_mode_raw, - ext.vendor_type AS vendor_type, - ext.vendor_game_id AS vendor_game_id, - ext.launch_mode AS launch_mode, - ext.package_version AS ext_package_version, - ext.preview_url AS ext_preview_url, - ext.package_url AS package_url, - ext.orientation AS ext_orientation, - ext.safe_height AS ext_safe_height, - ext.currency_icon AS currency_icon, - ext.gsp AS ext_gsp, - cat.name AS catalog_name, - cat.cover AS catalog_cover, - cat.preview_url AS catalog_preview_url, - cat.download_url AS catalog_download_url, - cat.package_version AS catalog_package_version, - cat.orientation AS catalog_orientation, - cat.safe_height AS catalog_safe_height, - cat.status AS catalog_status - `). - Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). - Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). - Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType). - Limit(1). - Scan(&row).Error - if err != nil { - return gameListRow{}, err - } - if strings.TrimSpace(row.InternalGameID) != "" { - return row, nil - } - - var catalog model.BaishunGameCatalog - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID). - First(&catalog).Error; err == nil { - return gameListRow{ - SysOrigin: sysOrigin, - InternalGameID: catalog.InternalGameID, - Name: catalog.Name, - Cover: catalog.Cover, - VendorType: baishunVendorType, - VendorGameID: strconv.Itoa(catalog.VendorGameID), - LaunchMode: baishunLaunchModeRemote, - CatalogCover: catalog.Cover, - CatalogPreviewURL: catalog.PreviewURL, - CatalogDownloadURL: catalog.DownloadURL, - CatalogPackageVersion: catalog.PackageVersion, - CatalogSafeHeight: catalog.SafeHeight, - CatalogOrientation: catalog.Orientation, - CatalogStatus: catalog.Status, - GameModeRaw: catalog.GameModeJSON, - }, nil - } - return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found") -} - -func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatformGame, error) { - timestamp := time.Now().Unix() - nonce := s.randomNonce() - payload := map[string]any{ - "game_list_type": baishunGameListTypeGame, - "app_channel": s.cfg.BaishunAppChannel, - "app_id": s.cfg.BaishunAppID, - "signature": s.signBAISHUN(timestamp, nonce), - "signature_nonce": nonce, - "timestamp": timestamp, - } - if strings.TrimSpace(s.cfg.BaishunAppName) != "" { - payload["app_name"] = s.cfg.BaishunAppName - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.BaishunPlatformBaseURL, "/")+"/v1/api/gamelist", bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode >= http.StatusBadRequest { - return nil, fmt.Errorf("baishun platform failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - var result baishunPlatformResponse - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - if result.Code != 0 { - return nil, fmt.Errorf("baishun platform failed: code=%d message=%s", result.Code, result.Message) - } - return result.Data, nil -} - -func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) bool { - if s.cfg.BaishunAppID != 0 && appID != 0 && appID != s.cfg.BaishunAppID { - return false - } - if strings.TrimSpace(appChannel) != "" && s.cfg.BaishunAppChannel != "" && appChannel != s.cfg.BaishunAppChannel { - return false - } - if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(s.cfg.BaishunAppKey) == "" { - return false - } - if abs64(time.Now().Unix()-timestamp) > 15 { - return false - } - expected := s.signBAISHUN(timestamp, nonce) - if !strings.EqualFold(expected, signature) { - return false - } - return s.rememberNonce(ctx, nonce) -} - -func (s *BaishunService) rememberNonce(ctx context.Context, nonce string) bool { - ok, err := s.repo.Redis.SetNX(ctx, "bs:sign:nonce:"+nonce, "1", 15*time.Second).Result() - return err == nil && ok -} - -func (s *BaishunService) signBAISHUN(timestamp int64, nonce string) string { - sum := md5.Sum([]byte(nonce + s.cfg.BaishunAppKey + strconv.FormatInt(timestamp, 10))) - return hex.EncodeToString(sum[:]) -} - -func (s *BaishunService) randomNonce() string { - token, err := s.randomToken(8) - if err != nil { - return fmt.Sprintf("%d", time.Now().UnixNano()) - } - return token -} - -func (s *BaishunService) randomToken(size int) (string, error) { - buf := make([]byte, size) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -func (s *BaishunService) uniqueID() string { - id, err := util.NextID() - if err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 10) - } - return strconv.FormatInt(id, 10) -} - -func (s *BaishunService) readUserInfo(ctx context.Context, userID int64) integration.UserProfile { - profile, err := s.java.GetUserProfile(ctx, userID) - if err != nil { - return integration.UserProfile{ID: integration.Int64Value(userID)} - } - return profile -} - -func (s *BaishunService) readUserBalance(ctx context.Context, userID int64) int64 { - balanceMap, err := s.java.MapGoldBalance(ctx, []int64{userID}) - if err != nil { - return 0 - } - return balanceMap[userID] -} - -func (s *BaishunService) resolveLanguage(ctx context.Context, userID int64) string { - language, err := s.java.GetUserLanguage(ctx, userID) - if err != nil || strings.TrimSpace(language) == "" { - return "0" - } - return language -} - -func (s *BaishunService) findSessionByToken(ctx context.Context, ssToken, userID string) (model.BaishunLaunchSession, bool) { - var session model.BaishunLaunchSession - err := s.repo.DB.WithContext(ctx). - Where("ss_token = ?", strings.TrimSpace(ssToken)). - First(&session).Error - if err != nil || time.Now().After(derefTime(session.SSTokenExpireTime)) { - return model.BaishunLaunchSession{}, false - } - if strings.TrimSpace(userID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(userID) { - return model.BaishunLaunchSession{}, false - } - return session, true -} - -func (s *BaishunService) persistOrderRecord(ctx context.Context, sysOrigin string, req BaishunChangeBalanceRequest, walletEventID, status, requestJSON, responseJSON string, assetRecordID *int64) { - id, err := util.NextID() - if err != nil { - return - } - record := model.BaishunOrderIdempotency{ - ID: id, - SysOrigin: sysOrigin, - OrderID: req.OrderID, - GameRoundID: req.GameRoundID, - RoomID: req.RoomID, - UserID: parseInt64Default(req.UserID), - VendorGameID: req.GameID, - CurrencyDiff: req.CurrencyDiff, - DiffMsg: req.DiffMsg, - MsgType: req.MsgType, - CurrencyType: req.CurrencyType, - WalletEventID: walletEventID, - WalletAssetRecordID: assetRecordID, - Status: status, - RequestJSON: requestJSON, - ResponseJSON: responseJSON, - CreateTime: time.Now(), - UpdateTime: time.Now(), - } - _ = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sys_origin"}, {Name: "order_id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "wallet_event_id", "wallet_asset_record_id", "status", "request_json", "response_json", "update_time", - }), - }).Create(&record).Error -} - -func (s *BaishunService) saveCallbackLog(ctx context.Context, requestType, requestJSON, responseJSON, sysOrigin, userID string, orderID, gameRoundID *string, status string, bizCode int, bizMessage string) { - id, err := util.NextID() - if err != nil { - return - } - logRecord := model.BaishunCallbackLog{ - ID: id, - SysOrigin: sysOrigin, - RequestType: requestType, - RequestJSON: requestJSON, - ResponseJSON: responseJSON, - BizCode: strconv.Itoa(bizCode), - BizMessage: bizMessage, - Status: status, - CreateTime: time.Now(), - UpdateTime: time.Now(), - } - if orderID != nil { - logRecord.OrderID = *orderID - } - if gameRoundID != nil { - logRecord.GameRoundID = *gameRoundID - } - if strings.TrimSpace(userID) != "" { - parsed := parseInt64Default(userID) - logRecord.UserID = &parsed - } - err = s.repo.DB.WithContext(ctx).Create(&logRecord).Error - if err != nil && isBizMessageTooLongError(err) { - logRecord.BizMessage = truncateRunes(logRecord.BizMessage, legacyBizMessageRuneLimit) - _ = s.repo.DB.WithContext(ctx).Create(&logRecord).Error - } -} - -func (s *BaishunService) failStandard(code int, message string) baishunStandardResponse { - return baishunStandardResponse{ - Code: code, - Message: defaultIfBlank(message, "failed"), - UniqueID: s.uniqueID(), - } -} - -func (r gameListRow) toListItem() RoomGameListItem { - return RoomGameListItem{ - GameID: r.InternalGameID, - VendorType: defaultIfBlank(r.VendorType, baishunVendorType), - VendorGameID: r.vendorGameIDInt(), - Name: r.name(), - Cover: r.cover(), - Category: r.Category, - Sort: r.Sort, - LaunchMode: r.launchMode(), - FullScreen: r.FullScreen, - GameMode: r.gameMode(), - SafeHeight: r.safeHeight(), - Orientation: r.orientation(), - PackageVersion: r.packageVersion(), - Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled), - } -} - -func (r gameListRow) name() string { - return defaultIfBlank(r.Name, r.CatalogName) -} - -func (r gameListRow) cover() string { - return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL())) -} - -func (r gameListRow) previewURL() string { - return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL) -} - -func (r gameListRow) downloadURL() string { - return defaultIfBlank(r.PackageURL, r.CatalogDownloadURL) -} - -func (r gameListRow) entryURL() string { - switch r.launchMode() { - case baishunLaunchModeLocalZip: - if strings.TrimSpace(r.PackageURL) != "" { - return r.PackageURL - } - } - if strings.TrimSpace(r.CatalogDownloadURL) != "" { - return r.CatalogDownloadURL - } - return defaultIfBlank(r.PackageURL, defaultIfBlank(r.ExtPreviewURL, "mock://baishun")) -} - -func (r gameListRow) packageVersion() string { - return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) -} - -func (r gameListRow) safeHeight() int { - if r.ExtSafeHeight > 0 { - return r.ExtSafeHeight - } - return r.CatalogSafeHeight -} - -func (r gameListRow) orientation() int { - if r.ExtOrientation != nil { - return *r.ExtOrientation - } - return derefInt(r.CatalogOrientation) -} - -func (r gameListRow) launchMode() string { - return defaultIfBlank(r.LaunchMode, baishunLaunchModeRemote) -} - -func (r gameListRow) gameMode() int { - if parsed := pickFirstInt(r.GameModeRaw, 3); parsed > 0 { - return parsed - } - return 3 -} - -func (r gameListRow) vendorGameIDInt() int { - parsed, _ := strconv.Atoi(strings.TrimSpace(r.VendorGameID)) - return parsed -} - -func (r gameListRow) currencyIcon() string { - return r.CurrencyIcon -} - -func (s *BaishunService) resolveGSP(row gameListRow) int { - if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 { - return parsed - } - if s.cfg.BaishunGSP > 0 { - return s.cfg.BaishunGSP - } - return 101 -} - -func defaultIfBlank(value, fallback string) string { - if strings.TrimSpace(value) != "" { - return value - } - return fallback -} - -func pickFirstInt(raw string, fallback int) int { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback - } - var list []int - if strings.HasPrefix(raw, "[") { - if json.Unmarshal([]byte(raw), &list) == nil && len(list) > 0 { - return list[len(list)-1] - } - } - if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { - return parsed - } - return fallback -} - -func receiptTypeFromDiff(diff int64) string { - if diff >= 0 { - return "INCOME" - } - return "EXPENDITURE" -} - -func mustJSONString(value interface{}) string { - data, err := json.Marshal(value) - if err != nil { - return "" - } - return string(data) -} - -func abs64(value int64) int64 { - if value < 0 { - return -value - } - return value -} - -func isBizMessageTooLongError(err error) bool { - if err == nil { - return false - } - message := strings.ToLower(err.Error()) - return strings.Contains(message, "data too long") && strings.Contains(message, "biz_message") -} - -func truncateRunes(value string, limit int) string { - if limit <= 0 || value == "" { - return "" - } - runes := []rune(value) - if len(runes) <= limit { - return value - } - return string(runes[:limit]) -} - -func parseInt64Default(value string) int64 { - parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) - return parsed -} - -func sanitizeRoomID(roomID string) string { - roomID = strings.ReplaceAll(strings.TrimSpace(roomID), " ", "_") - if roomID == "" { - return "room" - } - return roomID -} - -func intPtr(value int) *int { - return &value -} - -func derefInt(value *int) int { - if value == nil { - return 0 - } - return *value -} - -func derefTime(value *time.Time) time.Time { - if value == nil { - return time.Unix(0, 0) - } - return *value -} - -func stringPtr(value string) *string { - if strings.TrimSpace(value) == "" { - return nil - } - return &value -} - -func toString(value any) string { - if value == nil { - return "" - } - switch typed := value.(type) { - case string: - return typed - case json.Number: - return typed.String() - default: - return fmt.Sprint(typed) - } -} diff --git a/internal/service/baishun/aliases.go b/internal/service/baishun/aliases.go new file mode 100644 index 0000000..4f29d4e --- /dev/null +++ b/internal/service/baishun/aliases.go @@ -0,0 +1,8 @@ +package baishun + +import "chatapp3-golang/internal/common" + +type AuthUser = common.AuthUser +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/baishun/callback.go b/internal/service/baishun/callback.go new file mode 100644 index 0000000..7f5287f --- /dev/null +++ b/internal/service/baishun/callback.go @@ -0,0 +1,408 @@ +package baishun + +import ( + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "gorm.io/gorm/clause" +) + +// HandleToken 处理百顺获取启动 code/token 的回调。 +func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + var session model.BaishunLaunchSession + if err := s.repo.DB.WithContext(ctx). + Where("launch_code = ?", strings.TrimSpace(req.Code)). + First(&session).Error; err != nil { + resp := s.failStandard(baishunCodeTokenExpired, "code invalid") + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if session.Status != baishunSessionCodeIssued || time.Now().After(session.LaunchCodeExpireTime) { + resp := s.failStandard(baishunCodeTokenExpired, "code expired") + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if strings.TrimSpace(req.UserID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(req.UserID) { + resp := s.failStandard(baishunCodeTokenExpired, "code invalid") + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + ssToken, err := s.randomToken(32) + if err != nil { + resp := s.failStandard(baishunCodeServerError, "token create failed") + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + expire := time.Now().Add(time.Duration(s.cfg.Baishun.SSTokenTTLSeconds) * time.Second) + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{ + "ss_token": ssToken, + "ss_token_expire_time": expire, + "status": baishunSessionTokenIssued, + "update_time": time.Now(), + }).Error; err != nil { + resp := s.failStandard(baishunCodeServerError, "session update failed") + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + userInfo := s.readUserInfo(ctx, session.UserID) + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "ss_token": ssToken, + "expire_date": expire.UnixMilli(), + }, + UserInfo: map[string]any{ + "user_id": req.UserID, + "user_name": userInfo.UserNickname, + "user_avatar": userInfo.UserAvatar, + "balance": balance, + }, + } + s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +// HandleProfile 处理百顺获取用户资料的回调。 +func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "profile", rawJSON, utils.MustJSONString(resp, ""), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + userInfo := s.readUserInfo(ctx, session.UserID) + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "user_id": req.UserID, + "user_name": userInfo.UserNickname, + "user_avatar": userInfo.UserAvatar, + "balance": balance, + "balance_list": []map[string]any{{"name": "Gold", "currency_type": 0, "currency_amount": balance}}, + "user_type": 1, + }, + } + _ = s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{"status": baishunSessionActive, "update_time": time.Now()}).Error + s.saveCallbackLog(ctx, "profile", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +// HandleUpdateToken 处理百顺刷新 ss_token 的回调。 +func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "update-token", rawJSON, utils.MustJSONString(resp, ""), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + newToken, err := s.randomToken(32) + if err != nil { + resp := s.failStandard(baishunCodeServerError, "token create failed") + s.saveCallbackLog(ctx, "update-token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + expire := time.Now().Add(time.Duration(s.cfg.Baishun.SSTokenTTLSeconds) * time.Second) + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{ + "ss_token": newToken, + "ss_token_expire_time": expire, + "status": baishunSessionActive, + "update_time": time.Now(), + }).Error; err != nil { + resp := s.failStandard(baishunCodeServerError, "session update failed") + s.saveCallbackLog(ctx, "update-token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "ss_token": newToken, + "expire_date": expire.UnixMilli(), + }, + } + s.saveCallbackLog(ctx, "update-token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +// HandleChangeBalance 处理百顺扣增币回调,并驱动钱包变更。 +func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), "", req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if req.CurrencyDiff == 0 { + resp := s.failStandard(baishunCodeServerError, "currency_diff invalid") + s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + lockKey := fmt.Sprintf("bs:lock:user:%s:%s", session.SysOrigin, req.UserID) + lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", 5*time.Second).Result() + if err == nil && lockOK { + defer s.repo.Redis.Del(context.Background(), lockKey) + } + + var order model.BaishunOrderIdempotency + err = s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND order_id = ?", session.SysOrigin, req.OrderID). + First(&order).Error + if err == nil && order.Status == baishunOrderStatusSuccess && strings.TrimSpace(order.ResponseJSON) != "" { + var resp baishunStandardResponse + if json.Unmarshal([]byte(order.ResponseJSON), &resp) == nil { + s.saveCallbackLog(ctx, "change-balance", rawJSON, order.ResponseJSON, session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) + return resp + } + } + + walletEventID := fmt.Sprintf("BAISHUN:%s:%s", session.SysOrigin, req.OrderID) + exists, existsErr := s.java.ExistsGoldEvent(ctx, walletEventID) + if existsErr == nil && !exists { + changeErr := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: receiptTypeFromDiff(req.CurrencyDiff), + UserID: session.UserID, + SysOrigin: session.SysOrigin, + EventID: walletEventID, + Remark: defaultIfBlank(req.DiffMsg, req.MsgType), + Amount: integration.NewPennyAmountPayloadFromDollar(abs64(req.CurrencyDiff)), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: fmt.Sprintf("BAISHUN_GAME_%d", req.GameID), + CustomizeOriginDesc: fmt.Sprintf("BAISHUN GAME[%d]", req.GameID), + }) + if changeErr != nil { + message := changeErr.Error() + code := baishunCodeServerError + if strings.Contains(strings.ToLower(message), "insufficient") || strings.Contains(message, "5000") { + code = baishunCodeInsufficientAssets + } + resp := s.failStandard(code, message) + s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusFailed, rawJSON, utils.MustJSONString(resp, ""), nil) + s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + } + + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "currency_balance": balance, + }, + } + s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, utils.MustJSONString(resp, ""), nil) + s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +// HandleReport 处理百顺上报类回调,目前仅记录日志并返回成功。 +func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse { + userID := toString(payload["user_id"]) + orderID := toString(payload["order_id"]) + gameRoundID := toString(payload["game_round_id"]) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{}, + } + s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +// HandleBalanceInfo 处理百顺查询余额回调。 +func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse { + if !s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp) { + return baishunBalanceInfoResponse{Code: baishunCodeSignatureError, Msg: "signature error"} + } + userID, _ := strconv.ParseInt(req.UserID, 10, 64) + balance := s.readUserBalance(ctx, userID) + resp := baishunBalanceInfoResponse{ + Code: 0, + Msg: "success", + Data: map[string]any{"cur_coin": balance}, + } + s.saveCallbackLog(ctx, "balance-info", rawJSON, utils.MustJSONString(resp, ""), "", req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Msg) + return resp +} + +// validateCommonSignature 校验百顺回调签名。 +func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) bool { + if s.cfg.Baishun.AppID != 0 && appID != 0 && appID != s.cfg.Baishun.AppID { + return false + } + if strings.TrimSpace(appChannel) != "" && s.cfg.Baishun.AppChannel != "" && appChannel != s.cfg.Baishun.AppChannel { + return false + } + if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(s.cfg.Baishun.AppKey) == "" { + return false + } + if abs64(time.Now().Unix()-timestamp) > 15 { + return false + } + expected := s.signBAISHUN(timestamp, nonce) + if !strings.EqualFold(expected, signature) { + return false + } + return s.rememberNonce(ctx, nonce) +} + +// rememberNonce 记录已用 nonce,防止回调重放。 +func (s *BaishunService) rememberNonce(ctx context.Context, nonce string) bool { + ok, err := s.repo.Redis.SetNX(ctx, "bs:sign:nonce:"+nonce, "1", 15*time.Second).Result() + return err == nil && ok +} + +// signBAISHUN 按百顺约定生成签名。 +func (s *BaishunService) signBAISHUN(timestamp int64, nonce string) string { + sum := md5.Sum([]byte(nonce + s.cfg.Baishun.AppKey + strconv.FormatInt(timestamp, 10))) + return hex.EncodeToString(sum[:]) +} + +// randomNonce 生成随机 nonce。 +func (s *BaishunService) randomNonce() string { + token, err := s.randomToken(8) + if err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return token +} + +// randomToken 生成指定长度的随机 token。 +func (s *BaishunService) randomToken(size int) (string, error) { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// uniqueID 生成一条可返回给百顺的唯一 ID。 +func (s *BaishunService) uniqueID() string { + id, err := utils.NextID() + if err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return strconv.FormatInt(id, 10) +} + +// persistOrderRecord 持久化扣增币回调的幂等记录。 +func (s *BaishunService) persistOrderRecord(ctx context.Context, sysOrigin string, req BaishunChangeBalanceRequest, walletEventID, status, requestJSON, responseJSON string, assetRecordID *int64) { + id, err := utils.NextID() + if err != nil { + return + } + record := model.BaishunOrderIdempotency{ + ID: id, + SysOrigin: sysOrigin, + OrderID: req.OrderID, + GameRoundID: req.GameRoundID, + RoomID: req.RoomID, + UserID: parseInt64Default(req.UserID), + VendorGameID: req.GameID, + CurrencyDiff: req.CurrencyDiff, + DiffMsg: req.DiffMsg, + MsgType: req.MsgType, + CurrencyType: req.CurrencyType, + WalletEventID: walletEventID, + WalletAssetRecordID: assetRecordID, + Status: status, + RequestJSON: requestJSON, + ResponseJSON: responseJSON, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + _ = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "order_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "wallet_event_id", "wallet_asset_record_id", "status", "request_json", "response_json", "update_time", + }), + }).Create(&record).Error +} + +// saveCallbackLog 保存百顺回调审计日志。 +func (s *BaishunService) saveCallbackLog(ctx context.Context, requestType, requestJSON, responseJSON, sysOrigin, userID string, orderID, gameRoundID *string, status string, bizCode int, bizMessage string) { + id, err := utils.NextID() + if err != nil { + return + } + logRecord := model.BaishunCallbackLog{ + ID: id, + SysOrigin: sysOrigin, + RequestType: requestType, + RequestJSON: requestJSON, + ResponseJSON: responseJSON, + BizCode: strconv.Itoa(bizCode), + BizMessage: bizMessage, + Status: status, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if orderID != nil { + logRecord.OrderID = *orderID + } + if gameRoundID != nil { + logRecord.GameRoundID = *gameRoundID + } + if strings.TrimSpace(userID) != "" { + parsed := parseInt64Default(userID) + logRecord.UserID = &parsed + } + err = s.repo.DB.WithContext(ctx).Create(&logRecord).Error + if err != nil && isBizMessageTooLongError(err) { + logRecord.BizMessage = truncateRunes(logRecord.BizMessage, legacyBizMessageRuneLimit) + _ = s.repo.DB.WithContext(ctx).Create(&logRecord).Error + } +} + +// failStandard 构造百顺标准失败回包。 +func (s *BaishunService) failStandard(code int, message string) baishunStandardResponse { + return baishunStandardResponse{ + Code: code, + Message: defaultIfBlank(message, "failed"), + UniqueID: s.uniqueID(), + } +} diff --git a/internal/service/baishun/catalog.go b/internal/service/baishun/catalog.go new file mode 100644 index 0000000..34bceae --- /dev/null +++ b/internal/service/baishun/catalog.go @@ -0,0 +1,131 @@ +package baishun + +import ( + "bytes" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "gorm.io/gorm/clause" +) + +// SyncCatalog 从百顺平台拉取游戏目录并同步到本地表。 +func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { + if strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL) == "" || s.cfg.Baishun.AppID == 0 { + return nil, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun platform config is missing") + } + sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI") + if strings.TrimSpace(sysOrigin) == "" { + sysOrigin = "LIKEI" + } + + games, err := s.fetchPlatformGames(ctx) + if err != nil { + return nil, err + } + filter := make(map[int]struct{}, len(req.VendorGameIDs)) + for _, id := range req.VendorGameIDs { + filter[id] = struct{}{} + } + + resp := &SyncCatalogResponse{} + for _, game := range games { + if len(filter) > 0 { + if _, ok := filter[game.GameID]; !ok { + resp.Skipped++ + continue + } + } + id, err := utils.NextID() + if err != nil { + return nil, err + } + record := model.BaishunGameCatalog{ + ID: id, + SysOrigin: sysOrigin, + InternalGameID: fmt.Sprintf("bs_%d", game.GameID), + VendorGameID: game.GameID, + Name: game.Name, + Cover: defaultIfBlank(game.PreviewURL, game.DownloadURL), + PreviewURL: game.PreviewURL, + DownloadURL: game.DownloadURL, + PackageVersion: game.GameVersion, + GameModeJSON: utils.MustJSONString(game.GameMode, ""), + Orientation: intPtr(game.GameOrientation), + SafeHeight: game.SafeHeight, + VenueLevelJSON: utils.MustJSONString(game.VenueLevel, ""), + GSP: strconv.Itoa(s.cfg.Baishun.GSP), + RawJSON: utils.MustJSONString(game, ""), + Status: baishunCatalogEnabled, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "internal_game_id", "name", "cover", "preview_url", "download_url", + "package_version", "game_mode_json", "orientation", "safe_height", + "venue_level_json", "gsp", "raw_json", "status", "update_time", + }), + }).Create(&record).Error + if err != nil { + return nil, err + } + if req.Force { + resp.Updated++ + } else { + resp.Inserted++ + } + } + return resp, nil +} + +// fetchPlatformGames 从百顺平台拉取游戏目录。 +func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatformGame, error) { + timestamp := time.Now().Unix() + nonce := s.randomNonce() + payload := map[string]any{ + "game_list_type": baishunGameListTypeGame, + "app_channel": s.cfg.Baishun.AppChannel, + "app_id": s.cfg.Baishun.AppID, + "signature": s.signBAISHUN(timestamp, nonce), + "signature_nonce": nonce, + "timestamp": timestamp, + } + if strings.TrimSpace(s.cfg.Baishun.AppName) != "" { + payload["app_name"] = s.cfg.Baishun.AppName + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.Baishun.PlatformBaseURL, "/")+"/v1/api/gamelist", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("baishun platform failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var result baishunPlatformResponse + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + if result.Code != 0 { + return nil, fmt.Errorf("baishun platform failed: code=%d message=%s", result.Code, result.Message) + } + return result.Data, nil +} diff --git a/internal/service/baishun/convert.go b/internal/service/baishun/convert.go new file mode 100644 index 0000000..a9aeaa7 --- /dev/null +++ b/internal/service/baishun/convert.go @@ -0,0 +1,131 @@ +package baishun + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// defaultIfBlank 在字符串为空时返回默认值。 +func defaultIfBlank(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +// pickFirstInt 从字符串中取第一个整数,失败时返回默认值。 +func pickFirstInt(raw string, fallback int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + var list []int + if strings.HasPrefix(raw, "[") { + if json.Unmarshal([]byte(raw), &list) == nil && len(list) > 0 { + return list[len(list)-1] + } + } + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + return parsed + } + return fallback +} + +// receiptTypeFromDiff 根据金额正负映射钱包收支类型。 +func receiptTypeFromDiff(diff int64) string { + if diff >= 0 { + return "INCOME" + } + return "EXPENDITURE" +} + +// abs64 返回 int64 的绝对值。 +func abs64(value int64) int64 { + if value < 0 { + return -value + } + return value +} + +// isBizMessageTooLongError 判断是否是业务描述超长错误。 +func isBizMessageTooLongError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "data too long") && strings.Contains(message, "biz_message") +} + +// truncateRunes 按 rune 长度截断字符串。 +func truncateRunes(value string, limit int) string { + if limit <= 0 || value == "" { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +// parseInt64Default 解析字符串整数,失败时返回 0。 +func parseInt64Default(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} + +// sanitizeRoomID 规范化房间 ID。 +func sanitizeRoomID(roomID string) string { + roomID = strings.ReplaceAll(strings.TrimSpace(roomID), " ", "_") + if roomID == "" { + return "room" + } + return roomID +} + +// intPtr 返回 int 指针。 +func intPtr(value int) *int { + return &value +} + +// derefInt 安全解引用 int 指针。 +func derefInt(value *int) int { + if value == nil { + return 0 + } + return *value +} + +// derefTime 安全解引用时间指针。 +func derefTime(value *time.Time) time.Time { + if value == nil { + return time.Unix(0, 0) + } + return *value +} + +// stringPtr 返回 string 指针。 +func stringPtr(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + return &value +} + +// toString 宽松地把任意值转换成字符串。 +func toString(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + default: + return fmt.Sprint(typed) + } +} diff --git a/internal/service/baishun/game_row.go b/internal/service/baishun/game_row.go new file mode 100644 index 0000000..c6aa6a3 --- /dev/null +++ b/internal/service/baishun/game_row.go @@ -0,0 +1,105 @@ +package baishun + +import ( + "strconv" + "strings" +) + +// toListItem 把联表行模型转换成游戏列表项。 +func (r gameListRow) toListItem() RoomGameListItem { + return RoomGameListItem{ + GameID: r.InternalGameID, + VendorType: defaultIfBlank(r.VendorType, baishunVendorType), + VendorGameID: r.vendorGameIDInt(), + Name: r.name(), + Cover: r.cover(), + Category: r.Category, + Sort: r.Sort, + LaunchMode: r.launchMode(), + FullScreen: r.FullScreen, + GameMode: r.gameMode(), + SafeHeight: r.safeHeight(), + Orientation: r.orientation(), + PackageVersion: r.packageVersion(), + Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled), + } +} + +// name 返回游戏展示名称,优先取目录名称。 +func (r gameListRow) name() string { + return defaultIfBlank(r.Name, r.CatalogName) +} + +// cover 返回游戏封面,优先取目录封面。 +func (r gameListRow) cover() string { + return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL())) +} + +// previewURL 返回游戏预览地址。 +func (r gameListRow) previewURL() string { + return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL) +} + +// downloadURL 返回游戏下载地址。 +func (r gameListRow) downloadURL() string { + return defaultIfBlank(r.PackageURL, r.CatalogDownloadURL) +} + +// entryURL 返回游戏实际启动入口。 +func (r gameListRow) entryURL() string { + switch r.launchMode() { + case baishunLaunchModeLocalZip: + if strings.TrimSpace(r.PackageURL) != "" { + return r.PackageURL + } + } + if strings.TrimSpace(r.CatalogDownloadURL) != "" { + return r.CatalogDownloadURL + } + return defaultIfBlank(r.PackageURL, defaultIfBlank(r.ExtPreviewURL, "mock://baishun")) +} + +// packageVersion 返回游戏包版本。 +func (r gameListRow) packageVersion() string { + return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) +} + +// safeHeight 返回游戏安全区域高度。 +func (r gameListRow) safeHeight() int { + if r.ExtSafeHeight > 0 { + return r.ExtSafeHeight + } + return r.CatalogSafeHeight +} + +// orientation 返回游戏屏幕方向。 +func (r gameListRow) orientation() int { + if r.ExtOrientation != nil { + return *r.ExtOrientation + } + return derefInt(r.CatalogOrientation) +} + +// launchMode 返回游戏启动模式。 +func (r gameListRow) launchMode() string { + return defaultIfBlank(r.LaunchMode, baishunLaunchModeRemote) +} + +// gameMode 返回游戏模式。 +func (r gameListRow) gameMode() int { + if parsed := pickFirstInt(r.GameModeRaw, 3); parsed > 0 { + return parsed + } + return 3 +} + +// vendorGameIDInt 返回厂商游戏 ID 的整数值。 +func (r gameListRow) vendorGameIDInt() int { + parsed, _ := strconv.Atoi(strings.TrimSpace(r.VendorGameID)) + return parsed +} + +// currencyIcon 返回货币图标地址。 +func (r gameListRow) currencyIcon() string { + return r.CurrencyIcon +} diff --git a/internal/service/baishun/launch.go b/internal/service/baishun/launch.go new file mode 100644 index 0000000..19ffafe --- /dev/null +++ b/internal/service/baishun/launch.go @@ -0,0 +1,263 @@ +package baishun + +import ( + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// LaunchGame 创建会话、拉起百顺游戏并更新房间状态。 +func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req BaishunLaunchAppRequest, clientIP string) (*BaishunLaunchAppResponse, error) { + if strings.TrimSpace(req.RoomID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + if strings.TrimSpace(req.GameID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "gameId is required") + } + + row, err := s.findGameRow(ctx, user.SysOrigin, req.GameID) + if err != nil { + return nil, err + } + + sessionID, err := utils.NextID() + if err != nil { + return nil, err + } + sessionDBID, err := utils.NextID() + if err != nil { + return nil, err + } + roomStateID, err := utils.NextID() + if err != nil { + return nil, err + } + launchCode, err := s.randomToken(24) + if err != nil { + return nil, err + } + + now := time.Now() + session := model.BaishunLaunchSession{ + ID: sessionDBID, + SysOrigin: user.SysOrigin, + RoomID: req.RoomID, + UserID: user.UserID, + InternalGameID: row.InternalGameID, + VendorGameID: row.vendorGameIDInt(), + GameSessionID: fmt.Sprintf("bs_%s_%d", sanitizeRoomID(req.RoomID), sessionID), + LaunchCode: launchCode, + LaunchCodeExpireTime: now.Add(time.Duration(s.cfg.Baishun.LaunchCodeTTLSeconds) * time.Second), + Language: s.resolveLanguage(ctx, user.UserID), + GSP: s.resolveGSP(row), + CurrencyType: 0, + CurrencyIcon: row.currencyIcon(), + SceneMode: req.SceneMode, + GameMode: row.gameMode(), + PackageVersion: row.packageVersion(), + ClientIP: clientIP, + ClientOrigin: defaultIfBlank(req.ClientOrigin, "COMMON"), + Status: baishunSessionCodeIssued, + CreateTime: now, + UpdateTime: now, + } + + roomState := model.BaishunRoomState{ + ID: roomStateID, + SysOrigin: user.SysOrigin, + RoomID: req.RoomID, + HostUserID: user.UserID, + CurrentGameID: row.InternalGameID, + CurrentGameName: row.name(), + CurrentGameCover: row.cover(), + GameSessionID: session.GameSessionID, + LaunchUserID: &user.UserID, + State: baishunRoomStatePlaying, + StartTime: &now, + CreateTime: now, + UpdateTime: now, + } + vendorGameID := row.vendorGameIDInt() + roomState.CurrentVendorGameID = &vendorGameID + + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&session).Error; err != nil { + return err + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "room_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "host_user_id", "current_game_id", "current_vendor_game_id", "current_game_name", + "current_game_cover", "game_session_id", "launch_user_id", "state", "start_time", + "end_time", "update_time", + }), + }).Create(&roomState).Error + }); err != nil { + return nil, err + } + + return &BaishunLaunchAppResponse{ + GameSessionID: session.GameSessionID, + VendorType: baishunVendorType, + GameID: row.InternalGameID, + VendorGameID: row.vendorGameIDInt(), + Entry: BaishunLaunchEntry{ + LaunchMode: row.launchMode(), + EntryURL: row.entryURL(), + PreviewURL: row.previewURL(), + DownloadURL: row.downloadURL(), + PackageVersion: row.packageVersion(), + Orientation: row.orientation(), + SafeHeight: row.safeHeight(), + }, + BridgeConfig: BaishunBridgeConfig{ + AppName: s.cfg.Baishun.AppName, + AppChannel: defaultIfBlank(s.cfg.Baishun.AppChannel, "skychat"), + AppID: s.cfg.Baishun.AppID, + UserID: strconv.FormatInt(user.UserID, 10), + Code: launchCode, + RoomID: req.RoomID, + GameMode: strconv.Itoa(row.gameMode()), + Language: session.Language, + GSP: session.GSP, + GameConfig: BaishunGameConfig{ + SceneMode: req.SceneMode, + CurrencyIcon: session.CurrencyIcon, + }, + }, + RoomState: BaishunRoomStateResponse{ + RoomID: req.RoomID, + State: baishunRoomStatePlaying, + GameSessionID: session.GameSessionID, + CurrentGameID: row.InternalGameID, + CurrentVendorGameID: row.vendorGameIDInt(), + CurrentGameName: row.name(), + CurrentGameCover: row.cover(), + HostUserID: user.UserID, + }, + }, nil +} + +// CloseGame 关闭房间内当前游戏会话并回写房间状态。 +func (s *BaishunService) CloseGame(ctx context.Context, user AuthUser, req BaishunCloseAppRequest) (*BaishunRoomStateResponse, error) { + if strings.TrimSpace(req.RoomID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + now := time.Now() + if strings.TrimSpace(req.GameSessionID) != "" { + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("sys_origin = ? AND room_id = ? AND game_session_id = ?", user.SysOrigin, req.RoomID, req.GameSessionID). + Updates(map[string]any{ + "status": baishunSessionClosed, + "closed_reason": defaultIfBlank(req.Reason, "user_exit"), + "update_time": now, + }).Error; err != nil { + return nil, err + } + } + + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunRoomState{}). + Where("sys_origin = ? AND room_id = ?", user.SysOrigin, req.RoomID). + Updates(map[string]any{ + "state": baishunRoomStateIdle, + "end_time": now, + "game_session_id": "", + "current_game_id": "", + "current_vendor_game_id": nil, + "current_game_name": "", + "current_game_cover": "", + "update_time": now, + }).Error; err != nil { + return nil, err + } + + return &BaishunRoomStateResponse{ + RoomID: req.RoomID, + State: baishunRoomStateIdle, + }, nil +} + +// RefreshRoomState 根据最新会话状态刷新房间当前挂载的游戏信息。 +func (s *BaishunService) RefreshRoomState(ctx context.Context, sysOrigin, roomID string) (*BaishunRoomStateResponse, error) { + var state model.BaishunRoomState + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ?", sysOrigin, roomID). + First(&state).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil + } + return nil, err + } + if strings.TrimSpace(state.GameSessionID) == "" { + state.State = baishunRoomStateIdle + _ = s.repo.DB.WithContext(ctx).Model(&model.BaishunRoomState{}). + Where("id = ?", state.ID). + Update("state", baishunRoomStateIdle).Error + } + resp := &BaishunRoomStateResponse{ + RoomID: roomID, + State: defaultIfBlank(state.State, baishunRoomStateIdle), + GameSessionID: state.GameSessionID, + CurrentGameID: state.CurrentGameID, + CurrentGameName: state.CurrentGameName, + CurrentGameCover: state.CurrentGameCover, + HostUserID: state.HostUserID, + } + if state.CurrentVendorGameID != nil { + resp.CurrentVendorGameID = *state.CurrentVendorGameID + } + return resp, nil +} + +// readUserInfo 读取用户资料,用于回调和启动配置。 +func (s *BaishunService) readUserInfo(ctx context.Context, userID int64) integration.UserProfile { + profile, err := s.java.GetUserProfile(ctx, userID) + if err != nil { + return integration.UserProfile{ID: integration.Int64Value(userID)} + } + return profile +} + +// readUserBalance 读取用户金币余额。 +func (s *BaishunService) readUserBalance(ctx context.Context, userID int64) int64 { + balanceMap, err := s.java.MapGoldBalance(ctx, []int64{userID}) + if err != nil { + return 0 + } + return balanceMap[userID] +} + +// resolveLanguage 解析用户语言,缺省回退英文。 +func (s *BaishunService) resolveLanguage(ctx context.Context, userID int64) string { + language, err := s.java.GetUserLanguage(ctx, userID) + if err != nil || strings.TrimSpace(language) == "" { + return "0" + } + return language +} + +// findSessionByToken 按 ss_token 和用户查询会话。 +func (s *BaishunService) findSessionByToken(ctx context.Context, ssToken, userID string) (model.BaishunLaunchSession, bool) { + var session model.BaishunLaunchSession + err := s.repo.DB.WithContext(ctx). + Where("ss_token = ?", strings.TrimSpace(ssToken)). + First(&session).Error + if err != nil || time.Now().After(derefTime(session.SSTokenExpireTime)) { + return model.BaishunLaunchSession{}, false + } + if strings.TrimSpace(userID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(userID) { + return model.BaishunLaunchSession{}, false + } + return session, true +} diff --git a/internal/service/baishun/room.go b/internal/service/baishun/room.go new file mode 100644 index 0000000..0d23ed1 --- /dev/null +++ b/internal/service/baishun/room.go @@ -0,0 +1,232 @@ +package baishun + +import ( + "chatapp3-golang/internal/model" + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。 +func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { + items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "") + if err != nil { + return nil, err + } + if len(items) > 5 { + items = items[:5] + } + return items, nil +} + +// ListRoomGames 返回房间可启动的完整游戏列表。 +func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { + items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category) + if err != nil { + return nil, err + } + return &RoomGameListResponse{Items: items}, nil +} + +// GetRoomState 返回房间当前的游戏挂载状态。 +func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID string) (*BaishunRoomStateResponse, error) { + var state model.BaishunRoomState + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ?", user.SysOrigin, roomID). + First(&state).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil + } + return nil, err + } + resp := &BaishunRoomStateResponse{ + RoomID: roomID, + State: defaultIfBlank(state.State, baishunRoomStateIdle), + GameSessionID: state.GameSessionID, + CurrentGameID: state.CurrentGameID, + CurrentGameName: state.CurrentGameName, + CurrentGameCover: state.CurrentGameCover, + HostUserID: state.HostUserID, + } + if state.CurrentVendorGameID != nil { + resp.CurrentVendorGameID = *state.CurrentVendorGameID + } + return resp, nil +} + +// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 +func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { + var rows []gameListRow + query := s.repo.DB.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + cfg.game_id AS internal_game_id, + cfg.name AS name, + cfg.category AS category, + cfg.cover AS cover, + cfg.sort AS sort, + cfg.full_screen AS full_screen, + cfg.game_mode AS game_mode_raw, + ext.vendor_type AS vendor_type, + ext.vendor_game_id AS vendor_game_id, + ext.launch_mode AS launch_mode, + ext.package_version AS ext_package_version, + ext.preview_url AS ext_preview_url, + ext.package_url AS package_url, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + cat.name AS catalog_name, + cat.cover AS catalog_cover, + cat.preview_url AS catalog_preview_url, + cat.download_url AS catalog_download_url, + cat.package_version AS catalog_package_version, + cat.orientation AS catalog_orientation, + cat.safe_height AS catalog_safe_height, + cat.status AS catalog_status + `). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). + Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) + if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") { + query = query.Where("cfg.category = ?", category) + } + if strings.TrimSpace(roomID) != "" { + query = query.Where("cfg.game_mode LIKE ?", "%CHAT_ROOM%") + } + if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]RoomGameListItem, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toListItem()) + } + if len(items) > 0 { + return items, nil + } + + var catalogs []model.BaishunGameCatalog + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled). + Order("vendor_game_id ASC"). + Find(&catalogs).Error; err != nil { + return nil, err + } + for _, catalog := range catalogs { + gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)) + items = append(items, RoomGameListItem{ + GameID: gameID, + VendorType: baishunVendorType, + VendorGameID: catalog.VendorGameID, + Name: catalog.Name, + Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), + Category: "CHAT_ROOM", + Sort: int64(catalog.VendorGameID), + LaunchMode: baishunLaunchModeRemote, + FullScreen: true, + GameMode: pickFirstInt(catalog.GameModeJSON, 3), + SafeHeight: catalog.SafeHeight, + Orientation: derefInt(catalog.Orientation), + PackageVersion: catalog.PackageVersion, + Status: catalog.Status, + }) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Sort == items[j].Sort { + return items[i].VendorGameID < items[j].VendorGameID + } + return items[i].Sort > items[j].Sort + }) + return items, nil +} + +// findGameRow 按内部游戏 ID 查询游戏聚合信息。 +func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) { + var row gameListRow + err := s.repo.DB.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + cfg.game_id AS internal_game_id, + cfg.name AS name, + cfg.category AS category, + cfg.cover AS cover, + cfg.sort AS sort, + cfg.full_screen AS full_screen, + cfg.game_mode AS game_mode_raw, + ext.vendor_type AS vendor_type, + ext.vendor_game_id AS vendor_game_id, + ext.launch_mode AS launch_mode, + ext.package_version AS ext_package_version, + ext.preview_url AS ext_preview_url, + ext.package_url AS package_url, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + cat.name AS catalog_name, + cat.cover AS catalog_cover, + cat.preview_url AS catalog_preview_url, + cat.download_url AS catalog_download_url, + cat.package_version AS catalog_package_version, + cat.orientation AS catalog_orientation, + cat.safe_height AS catalog_safe_height, + cat.status AS catalog_status + `). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). + Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType). + Limit(1). + Scan(&row).Error + if err != nil { + return gameListRow{}, err + } + if strings.TrimSpace(row.InternalGameID) != "" { + return row, nil + } + + var catalog model.BaishunGameCatalog + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID). + First(&catalog).Error; err == nil { + return gameListRow{ + SysOrigin: sysOrigin, + InternalGameID: catalog.InternalGameID, + Name: catalog.Name, + Cover: catalog.Cover, + VendorType: baishunVendorType, + VendorGameID: strconv.Itoa(catalog.VendorGameID), + LaunchMode: baishunLaunchModeRemote, + CatalogCover: catalog.Cover, + CatalogPreviewURL: catalog.PreviewURL, + CatalogDownloadURL: catalog.DownloadURL, + CatalogPackageVersion: catalog.PackageVersion, + CatalogSafeHeight: catalog.SafeHeight, + CatalogOrientation: catalog.Orientation, + CatalogStatus: catalog.Status, + GameModeRaw: catalog.GameModeJSON, + }, nil + } + return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found") +} + +// resolveGSP 解析行模型中的 GSP 值。 +func (s *BaishunService) resolveGSP(row gameListRow) int { + if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 { + return parsed + } + if s.cfg.Baishun.GSP > 0 { + return s.cfg.Baishun.GSP + } + return 101 +} diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go new file mode 100644 index 0000000..8d6daad --- /dev/null +++ b/internal/service/baishun/types.go @@ -0,0 +1,319 @@ +package baishun + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "context" + "net/http" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + // 百顺接入相关的厂商、会话、房间和回调状态常量。 + baishunVendorType = "BAISHUN" + baishunLaunchModeRemote = "H5_REMOTE" + baishunLaunchModeLocalZip = "H5_ZIP_LOCAL" + baishunGameListTypeGame = 2 + baishunCatalogEnabled = "ENABLED" + baishunRoomStateIdle = "IDLE" + baishunRoomStatePlaying = "PLAYING" + baishunSessionInit = "INIT" + baishunSessionCodeIssued = "CODE_ISSUED" + baishunSessionTokenIssued = "TOKEN_ISSUED" + baishunSessionActive = "ACTIVE" + baishunSessionClosed = "CLOSED" + baishunSessionExpired = "EXPIRED" + baishunOrderStatusInit = "INIT" + baishunOrderStatusSuccess = "SUCCESS" + baishunOrderStatusFailed = "FAILED" + baishunCallbackStatusSuccess = "SUCCESS" + baishunCallbackStatusFailed = "FAILED" + baishunCodeInsufficientAssets = 1008 + baishunCodeTokenExpired = 1009 + baishunCodeSignatureError = 1010 + baishunCodeRepeatUnknown = 1011 + baishunCodeServerError = 1999 + legacyBizMessageRuneLimit = 255 +) + +type baishunDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type baishunGateway interface { + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) + GetUserLanguage(ctx context.Context, userID int64) (string, error) + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error +} + +type baishunPorts struct { + DB baishunDB + Redis redis.Cmdable +} + +// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。 +type BaishunService struct { + cfg config.Config + repo baishunPorts + java baishunGateway + httpClient *http.Client +} + +// NewBaishunService 创建百顺接入服务实例。 +func NewBaishunService(cfg config.Config, db baishunDB, cache redis.Cmdable, javaGateway baishunGateway) *BaishunService { + return &BaishunService{ + cfg: cfg, + repo: baishunPorts{DB: db, Redis: cache}, + java: javaGateway, + httpClient: &http.Client{ + Timeout: cfg.HTTP.Timeout, + }, + } +} + +// RoomGameListItem 是房间游戏列表中的单个游戏项。 +type RoomGameListItem struct { + GameID string `json:"gameId"` + VendorType string `json:"vendorType"` + VendorGameID int `json:"vendorGameId"` + Name string `json:"name"` + Cover string `json:"cover"` + Category string `json:"category,omitempty"` + Sort int64 `json:"sort,omitempty"` + LaunchMode string `json:"launchMode"` + FullScreen bool `json:"fullScreen"` + GameMode int `json:"gameMode"` + SafeHeight int `json:"safeHeight"` + Orientation int `json:"orientation"` + PackageVersion string `json:"packageVersion,omitempty"` + Status string `json:"status,omitempty"` +} + +// RoomGameListResponse 是房间游戏列表响应。 +type RoomGameListResponse struct { + Items []RoomGameListItem `json:"items"` +} + +// BaishunRoomStateResponse 是房间当前挂载游戏状态的响应体。 +type BaishunRoomStateResponse struct { + RoomID string `json:"roomId"` + State string `json:"state"` + GameSessionID string `json:"gameSessionId,omitempty"` + CurrentGameID string `json:"currentGameId,omitempty"` + CurrentVendorGameID int `json:"currentVendorGameId,omitempty"` + CurrentGameName string `json:"currentGameName,omitempty"` + CurrentGameCover string `json:"currentGameCover,omitempty"` + HostUserID int64 `json:"hostUserId,omitempty"` +} + +// BaishunLaunchAppRequest 是启动百顺游戏入参。 +type BaishunLaunchAppRequest struct { + RoomID string `json:"roomId"` + GameID string `json:"gameId"` + SceneMode int `json:"sceneMode"` + ClientOrigin string `json:"clientOrigin"` +} + +// BaishunCloseAppRequest 是关闭百顺游戏入参。 +type BaishunCloseAppRequest struct { + RoomID string `json:"roomId"` + GameSessionID string `json:"gameSessionId"` + Reason string `json:"reason"` +} + +// BaishunGameConfig 是传给 H5 Bridge 的游戏配置片段。 +type BaishunGameConfig struct { + SceneMode int `json:"sceneMode"` + CurrencyIcon string `json:"currencyIcon"` +} + +// BaishunBridgeConfig 是前端启动百顺 H5 所需的桥接配置。 +type BaishunBridgeConfig struct { + AppName string `json:"appName"` + AppChannel string `json:"appChannel"` + AppID int64 `json:"appId"` + UserID string `json:"userId"` + Code string `json:"code"` + RoomID string `json:"roomId"` + GameMode string `json:"gameMode"` + Language string `json:"language"` + GSP int `json:"gsp"` + GameConfig BaishunGameConfig `json:"gameConfig"` +} + +// BaishunLaunchEntry 描述游戏实际启动入口。 +type BaishunLaunchEntry struct { + LaunchMode string `json:"launchMode"` + EntryURL string `json:"entryUrl"` + PreviewURL string `json:"previewUrl"` + DownloadURL string `json:"downloadUrl"` + PackageVersion string `json:"packageVersion"` + Orientation int `json:"orientation"` + SafeHeight int `json:"safeHeight"` +} + +// BaishunLaunchAppResponse 是启动游戏接口返回体。 +type BaishunLaunchAppResponse struct { + GameSessionID string `json:"gameSessionId"` + VendorType string `json:"vendorType"` + GameID string `json:"gameId"` + VendorGameID int `json:"vendorGameId"` + Entry BaishunLaunchEntry `json:"entry"` + BridgeConfig BaishunBridgeConfig `json:"bridgeConfig"` + RoomState BaishunRoomStateResponse `json:"roomState"` +} + +// SyncCatalogRequest 是目录同步入参。 +type SyncCatalogRequest struct { + SysOrigin string `json:"sysOrigin"` + VendorGameIDs []int `json:"vendorGameIds"` + Force bool `json:"force"` +} + +// SyncCatalogResponse 是目录同步结果统计。 +type SyncCatalogResponse struct { + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` +} + +// BaishunTokenRequest 是百顺拉取 token/code 回调入参。 +type BaishunTokenRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + Code string `json:"code"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +// BaishunProfileRequest 是百顺查询用户资料回调入参。 +type BaishunProfileRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + ClientIP string `json:"client_ip"` + GameID int `json:"game_id"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +// BaishunUpdateTokenRequest 是百顺更新 token 回调入参。 +type BaishunUpdateTokenRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + GameID int `json:"game_id"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +// BaishunChangeBalanceRequest 是百顺扣增币回调入参。 +type BaishunChangeBalanceRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + CurrencyDiff int64 `json:"currency_diff"` + DiffMsg string `json:"diff_msg"` + GameID int `json:"game_id"` + GameRoundID string `json:"game_round_id"` + RoomID string `json:"room_id"` + ChangeTimeAt int64 `json:"change_time_at"` + OrderID string `json:"order_id"` + Extend string `json:"extend"` + MsgType string `json:"msg_type"` + CurrencyType *int `json:"currency_type"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +// BaishunBalanceInfoRequest 是百顺查询余额回调入参。 +type BaishunBalanceInfoRequest struct { + UserID string `json:"user_id"` + AppID int64 `json:"app_id"` + AppChannel string `json:"app_channel"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +// baishunStandardResponse 是百顺标准回包结构。 +type baishunStandardResponse struct { + Code int `json:"code"` + Message string `json:"message"` + UniqueID string `json:"unique_id,omitempty"` + Data interface{} `json:"data,omitempty"` + UserInfo interface{} `json:"user_info,omitempty"` +} + +// baishunBalanceInfoResponse 是百顺余额接口回包结构。 +type baishunBalanceInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +// gameListRow 是系统游戏表、厂商扩展表与目录表聚合后的内部行模型。 +type gameListRow struct { + ConfigID int64 + SysOrigin string + InternalGameID string + Name string + Category string + Cover string + Sort int64 + FullScreen bool + GameModeRaw string + VendorType string + VendorGameID string + LaunchMode string + ExtPackageVersion string + ExtPreviewURL string + PackageURL string + ExtOrientation *int + ExtSafeHeight int + CurrencyIcon string + ExtGSP string + CatalogName string + CatalogCover string + CatalogPreviewURL string + CatalogDownloadURL string + CatalogPackageVersion string + CatalogOrientation *int + CatalogSafeHeight int + CatalogStatus string +} + +// baishunPlatformResponse 是同步目录时平台返回的外层结构。 +type baishunPlatformResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data []baishunPlatformGame `json:"data"` +} + +// baishunPlatformOneGameResponse 是同步单个游戏时的平台返回结构。 +type baishunPlatformOneGameResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data baishunPlatformGame `json:"data"` +} + +// baishunPlatformGame 是百顺平台游戏目录中的单个游戏对象。 +type baishunPlatformGame struct { + GameID int `json:"game_id"` + Name string `json:"name"` + PreviewURL string `json:"preview_url"` + GameVersion string `json:"game_version"` + DownloadURL string `json:"download_url"` + GameMode []int `json:"game_mode"` + GameOrientation int `json:"game_orientation"` + SafeHeight int `json:"safe_height"` + VenueLevel []int `json:"venue_level"` +} diff --git a/internal/service/invite.go b/internal/service/invite.go deleted file mode 100644 index cdbb2d5..0000000 --- a/internal/service/invite.go +++ /dev/null @@ -1,1527 +0,0 @@ -package service - -import ( - "chatapp3-golang/internal/config" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/model" - "chatapp3-golang/internal/repo" - "chatapp3-golang/internal/util" - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "sort" - "strconv" - "strings" - "time" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -const ( - defaultTimezone = "Asia/Riyadh" - defaultShareTitle = "Invite Friends, Earn Big Rewards!" - defaultShareDesc = "Invite friends to join Party App and unlock exclusive rewards." - defaultValidRechargeCoins = int64(80000) - rewardLogStatusPending = "PENDING" - rewardLogStatusSuccess = "SUCCESS" - rewardLogStatusFailed = "FAILED" - relationStatusEligible = "ELIGIBLE" - relationStatusBlocked = "BLOCKED" - blockReasonDuplicateIP = "DUPLICATE_IP" - blockReasonDuplicateDevice = "DUPLICATE_DEVICE" - ruleTypeInviterBind = "INVITER_BIND" - ruleTypeInviteeBind = "INVITEE_BIND" - ruleTypeInviteeRecharge = "INVITEE_RECHARGE" - ruleTypeInviterValidCount = "INVITER_VALID_COUNT" - ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" -) - -var errAlreadyProcessed = errors.New("invite recharge already processed") - -type AppError struct { - Status int `json:"-"` - Code string `json:"code"` - Message string `json:"message"` -} - -func (e *AppError) Error() string { - return e.Message -} - -func NewAppError(status int, code, message string) *AppError { - return &AppError{Status: status, Code: code, Message: message} -} - -type AuthUser struct { - UserID int64 - SysOrigin string - Token string - Authorization string -} - -type RewardView struct { - RuleID int64 `json:"ruleId,omitempty"` - RuleType string `json:"ruleType,omitempty"` - Threshold int64 `json:"threshold,omitempty"` - GoldAmount int64 `json:"goldAmount"` - RewardGroupID *int64 `json:"rewardGroupId,omitempty"` - ValidThreshold int64 `json:"validThreshold,omitempty"` -} - -type TaskView struct { - RuleID int64 `json:"ruleId"` - RuleType string `json:"ruleType"` - Threshold int64 `json:"threshold"` - Progress int64 `json:"progress"` - GoldAmount int64 `json:"goldAmount"` - RewardGroupID *int64 `json:"rewardGroupId,omitempty"` - Achieved bool `json:"achieved"` - Claimed bool `json:"claimed"` -} - -type InviteStats struct { - TotalInvitedUsers int64 `json:"totalInvitedUsers"` - TotalValidUsers int64 `json:"totalValidUsers"` - TotalRechargeCoins int64 `json:"totalRechargeCoins"` - RewardGoldCoins int64 `json:"rewardGoldCoins"` -} - -type HomeResponse struct { - CampaignEnabled bool `json:"campaignEnabled"` - SysOrigin string `json:"sysOrigin"` - InviteCode string `json:"inviteCode"` - InviteLink string `json:"inviteLink"` - ShareTitle string `json:"shareTitle"` - ShareDesc string `json:"shareDesc"` - DownloadURL string `json:"downloadUrl"` - ServiceContact string `json:"serviceContact"` - Timezone string `json:"timezone"` - MonthKey string `json:"monthKey"` - ValidUserThreshold int64 `json:"validUserThreshold"` - InviterBindReward RewardView `json:"inviterBindReward"` - InviteeBindReward RewardView `json:"inviteeBindReward"` - InviteeRecharge []RewardView `json:"inviteeRechargeRules"` - ValidCountTasks []TaskView `json:"inviterValidCountTasks"` - TotalRechargeTasks []TaskView `json:"inviterTotalRechargeTasks"` - MonthlyProgress struct { - InviteCount int64 `json:"inviteCount"` - ValidUserCount int64 `json:"validUserCount"` - TotalRecharge int64 `json:"totalRechargeCoins"` - RewardGoldCoins int64 `json:"rewardGoldCoins"` - } `json:"monthlyProgress"` - Stats InviteStats `json:"stats"` -} - -type LandingResponse struct { - CampaignEnabled bool `json:"campaignEnabled"` - SysOrigin string `json:"sysOrigin"` - InviterUserID int64 `json:"inviterUserId"` - InviterNickname string `json:"inviterNickname"` - InviterAvatar string `json:"inviterAvatar"` - InviteCode string `json:"inviteCode"` - DownloadURL string `json:"downloadUrl"` - ShareTitle string `json:"shareTitle"` - ShareDesc string `json:"shareDesc"` - InviteeReward RewardView `json:"inviteeReward"` -} - -type BindCodeRequest struct { - InviteCode string `json:"inviteCode"` -} - -type BindCodeResponse struct { - RelationID int64 `json:"relationId"` - Eligible bool `json:"eligible"` - BlockReason string `json:"blockReason,omitempty"` - InviterUserID int64 `json:"inviterUserId"` - InviteCode string `json:"inviteCode"` - InviterReward RewardView `json:"inviterReward"` - InviteeReward RewardView `json:"inviteeReward"` -} - -type TaskClaimRequest struct { - RuleID int64 `json:"ruleId"` -} - -type TaskClaimResponse struct { - Claimed bool `json:"claimed"` - AlreadyClaimed bool `json:"alreadyClaimed"` - RuleID int64 `json:"ruleId"` - RuleType string `json:"ruleType"` - Progress int64 `json:"progress"` - Threshold int64 `json:"threshold"` - Reward RewardView `json:"reward"` -} - -type RechargeSuccessRequest struct { - OrderID string `json:"orderId"` - UserID int64 `json:"userId"` - RechargeCoins int64 `json:"rechargeCoins"` - PayTime any `json:"payTime"` - SysOrigin string `json:"sysOrigin"` -} - -type RechargeSuccessResponse struct { - Processed bool `json:"processed"` - AlreadyProcessed bool `json:"alreadyProcessed"` - HasInviteRelation bool `json:"hasInviteRelation"` - Eligible bool `json:"eligible"` - RelationID int64 `json:"relationId,omitempty"` - InviterUserID int64 `json:"inviterUserId,omitempty"` - TotalRechargeCoins int64 `json:"totalRechargeCoins,omitempty"` - ValidUser bool `json:"validUser"` - NewlyRewardedRuleIDs []int64 `json:"newlyRewardedRuleIds,omitempty"` -} - -type rewardDispatchInput struct { - SysOrigin string - MonthKey string - RelationID *int64 - InviterUserID *int64 - InviteeUserID *int64 - TargetUserID int64 - Reward RewardView - Scene string - RuleID *int64 - BusinessKey string - GoldOrigin string - PropsOrigin string - Remark string -} - -type rewardDispatchResult struct { - Success bool -} - -type campaignBundle struct { - Config model.InviteCampaignConfig `json:"config"` - Rules []model.InviteCampaignRewardRule `json:"rules"` -} - -type InviteService struct { - cfg config.Config - repo *repo.Repository - java *integration.Client -} - -func NewInviteService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *InviteService { - return &InviteService{ - cfg: cfg, - repo: repository, - java: javaClient, - } -} - -func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { - bundle, err := s.loadBundle(ctx, user.SysOrigin) - if err != nil { - return nil, err - } - inviteCode, err := s.ensureInviteCode(ctx, user) - if err != nil { - return nil, err - } - - now := time.Now() - timezone := normalizeTimezone(bundle.Config.Timezone) - monthKey := monthKeyAt(now, timezone) - monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) - if err != nil { - return nil, err - } - stats, err := s.loadStats(ctx, user.SysOrigin, user.UserID) - if err != nil { - return nil, err - } - claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey) - if err != nil { - return nil, err - } - - inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) - inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) - inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) - validCountRules := rulesByType(bundle.Rules, ruleTypeInviterValidCount) - totalRechargeRules := rulesByType(bundle.Rules, ruleTypeInviterTotalRecharge) - - resp := &HomeResponse{ - CampaignEnabled: bundle.Config.Enabled, - SysOrigin: user.SysOrigin, - InviteCode: inviteCode, - InviteLink: s.cfg.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(inviteCode), - ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), - ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), - DownloadURL: bundle.Config.DownloadURL, - ServiceContact: bundle.Config.ServiceContact, - Timezone: timezone, - MonthKey: monthKey, - ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), - InviterBindReward: rewardFromRule(inviterBindRule), - InviteeBindReward: rewardFromRule(inviteeBindRule), - InviteeRecharge: rewardsFromRules(inviteeRechargeRules), - Stats: stats, - } - resp.MonthlyProgress.InviteCount = monthly.InviteCount - resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount - resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge - resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins - resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) - resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) - return resp, nil -} - -func (s *InviteService) GetLanding(ctx context.Context, inviteCode string) (*LandingResponse, error) { - inviteCode = strings.TrimSpace(inviteCode) - if inviteCode == "" { - return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") - } - mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, NewAppError(404, "invite_code_not_found", "invite code not found") - } - return nil, err - } - inviter, err := s.findUserBaseInfo(ctx, mapping.UserID) - if err != nil { - return nil, err - } - bundle, err := s.loadBundle(ctx, inviter.OriginSys) - if err != nil { - return nil, err - } - inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) - return &LandingResponse{ - CampaignEnabled: bundle.Config.Enabled, - SysOrigin: inviter.OriginSys, - InviterUserID: inviter.ID, - InviterNickname: inviter.UserNickname, - InviterAvatar: inviter.UserAvatar, - InviteCode: mapping.InviteCode, - DownloadURL: bundle.Config.DownloadURL, - ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), - ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), - InviteeReward: rewardFromRule(inviteeBindRule), - }, nil -} - -func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) { - inviteCode := strings.TrimSpace(req.InviteCode) - if inviteCode == "" { - return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") - } - bundle, err := s.loadBundle(ctx, user.SysOrigin) - if err != nil { - return nil, err - } - if !bundle.Config.Enabled { - return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled") - } - - mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, NewAppError(404, "invite_code_not_found", "invite code not found") - } - return nil, err - } - if mapping.UserID == user.UserID { - return nil, NewAppError(400, "self_bind_not_allowed", "cannot bind your own invite code") - } - - inviterProfile, err := s.findUserBaseInfo(ctx, mapping.UserID) - if err != nil { - return nil, err - } - if inviterProfile.OriginSys != "" && inviterProfile.OriginSys != user.SysOrigin { - return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system") - } - - fingerprint := "" - if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { - fingerprint = strings.TrimSpace(value) - } - lockKeys := []string{ - fmt.Sprintf("invite:bind:lock:user:%s:%d", user.SysOrigin, user.UserID), - } - if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { - lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:ip:%s:%s", user.SysOrigin, strings.TrimSpace(clientIP))) - } - if bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { - lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:device:%s:%s", user.SysOrigin, fingerprint)) - } - - var ( - relationID int64 - eligibleStatus = relationStatusEligible - blockReason string - monthKey string - ) - err = s.withBindLocks(ctx, lockKeys, func() error { - if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { - return NewAppError(400, "already_bound", "invite code already bound") - } else if !errors.Is(lockErr, gorm.ErrRecordNotFound) { - return lockErr - } - - bindTime := time.Now() - monthKey = monthKeyAt(bindTime, normalizeTimezone(bundle.Config.Timezone)) - if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { - exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP) - if existsErr != nil { - return existsErr - } - if exists { - eligibleStatus = relationStatusBlocked - blockReason = blockReasonDuplicateIP - } - } - if eligibleStatus == relationStatusEligible && bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { - exists, existsErr := s.existsEligibleRelationByDevice(ctx, user.SysOrigin, fingerprint) - if existsErr != nil { - return existsErr - } - if exists { - eligibleStatus = relationStatusBlocked - blockReason = blockReasonDuplicateDevice - } - } - - nextRelationID, idErr := util.NextID() - if idErr != nil { - return idErr - } - progressID, idErr := util.NextID() - if idErr != nil { - return idErr - } - relationID = nextRelationID - relation := model.InviteCampaignRelation{ - ID: relationID, - SysOrigin: user.SysOrigin, - InviterUserID: mapping.UserID, - InviteeUserID: user.UserID, - InviteCode: inviteCode, - BindIP: strings.TrimSpace(clientIP), - DeviceFingerprint: fingerprint, - EligibleStatus: eligibleStatus, - BlockReason: blockReason, - BindTime: bindTime, - CreateTime: bindTime, - UpdateTime: bindTime, - } - progress := model.InviteCampaignInviteeProgress{ - ID: progressID, - RelationID: relationID, - SysOrigin: user.SysOrigin, - InviterUserID: mapping.UserID, - InviteeUserID: user.UserID, - TotalRecharge: 0, - ValidUser: false, - RewardedRuleIDs: "", - CreateTime: bindTime, - UpdateTime: bindTime, - } - - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&relation).Error; err != nil { - if isInviteRelationDuplicateErr(err) { - return NewAppError(400, "already_bound", "invite code already bound") - } - return err - } - if err := tx.Create(&progress).Error; err != nil { - return err - } - if eligibleStatus == relationStatusEligible { - if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { - item.InviteCount += 1 - }); err != nil { - return err - } - } - return nil - }) - }) - if err != nil { - return nil, err - } - - inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) - inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) - if eligibleStatus == relationStatusEligible { - inviterUserID := mapping.UserID - inviteeUserID := user.UserID - _, err = s.dispatchReward(ctx, rewardDispatchInput{ - SysOrigin: user.SysOrigin, - MonthKey: monthKey, - RelationID: refInt64(relationID), - InviterUserID: &inviterUserID, - InviteeUserID: &inviteeUserID, - TargetUserID: mapping.UserID, - Reward: rewardFromRule(inviterBindRule), - Scene: ruleTypeInviterBind, - RuleID: optionalRuleID(inviterBindRule), - BusinessKey: fmt.Sprintf("bind:inviter:%d", relationID), - GoldOrigin: "INVITED_NEW_USER_REWARD", - PropsOrigin: "INVITE_USER_REWARDS", - Remark: "resident invite inviter bind reward", - }) - if err != nil { - return nil, err - } - _, err = s.dispatchReward(ctx, rewardDispatchInput{ - SysOrigin: user.SysOrigin, - RelationID: refInt64(relationID), - InviterUserID: &inviterUserID, - InviteeUserID: &inviteeUserID, - TargetUserID: user.UserID, - Reward: rewardFromRule(inviteeBindRule), - Scene: ruleTypeInviteeBind, - RuleID: optionalRuleID(inviteeBindRule), - BusinessKey: fmt.Sprintf("bind:invitee:%d", relationID), - GoldOrigin: "INVITE_USER_REWARDS", - PropsOrigin: "INVITE_USER_REWARDS", - Remark: "resident invite invitee bind reward", - }) - if err != nil { - return nil, err - } - } - - return &BindCodeResponse{ - RelationID: relationID, - Eligible: eligibleStatus == relationStatusEligible, - BlockReason: blockReason, - InviterUserID: mapping.UserID, - InviteCode: inviteCode, - InviterReward: rewardFromRule(inviterBindRule), - InviteeReward: rewardFromRule(inviteeBindRule), - }, nil -} - -func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskClaimRequest) (*TaskClaimResponse, error) { - if req.RuleID == 0 { - return nil, NewAppError(400, "invalid_rule_id", "ruleId is required") - } - bundle, err := s.loadBundle(ctx, user.SysOrigin) - if err != nil { - return nil, err - } - rule := findRuleByID(bundle.Rules, req.RuleID) - if rule == nil || !rule.Enabled { - return nil, NewAppError(404, "rule_not_found", "rule not found") - } - if rule.RuleType != ruleTypeInviterValidCount && rule.RuleType != ruleTypeInviterTotalRecharge { - return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") - } - - monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) - claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID) - if err != nil { - return nil, err - } - monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) - if err != nil { - return nil, err - } - progressValue := monthly.TotalRecharge - if rule.RuleType == ruleTypeInviterValidCount { - progressValue = monthly.ValidUserCount - } - if claimed { - return &TaskClaimResponse{ - Claimed: true, - AlreadyClaimed: true, - RuleID: rule.ID, - RuleType: rule.RuleType, - Progress: progressValue, - Threshold: rule.ThresholdValue, - Reward: rewardFromRule(rule), - }, nil - } - if progressValue < rule.ThresholdValue { - return nil, NewAppError(400, "task_not_reached", "task threshold not reached") - } - - _, err = s.dispatchReward(ctx, rewardDispatchInput{ - SysOrigin: user.SysOrigin, - MonthKey: monthKey, - InviterUserID: refInt64(user.UserID), - TargetUserID: user.UserID, - Reward: rewardFromRule(rule), - Scene: rule.RuleType, - RuleID: refInt64(rule.ID), - BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, monthKey, rule.ID), - GoldOrigin: mapGoldOrigin(rule.RuleType), - PropsOrigin: mapPropsOrigin(rule.RuleType), - Remark: "resident invite monthly task reward", - }) - if err != nil { - return nil, err - } - - now := time.Now() - err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, monthKey, rule.ID, now); err != nil { - return err - } - return nil - }) - if err != nil { - return nil, err - } - - return &TaskClaimResponse{ - Claimed: true, - AlreadyClaimed: false, - RuleID: rule.ID, - RuleType: rule.RuleType, - Progress: progressValue, - Threshold: rule.ThresholdValue, - Reward: rewardFromRule(rule), - }, nil -} - -func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeSuccessRequest) (*RechargeSuccessResponse, error) { - req.OrderID = strings.TrimSpace(req.OrderID) - req.SysOrigin = strings.TrimSpace(req.SysOrigin) - if req.OrderID == "" { - return nil, NewAppError(400, "invalid_order_id", "orderId is required") - } - if req.UserID == 0 { - return nil, NewAppError(400, "invalid_user_id", "userId is required") - } - if req.RechargeCoins <= 0 { - return nil, NewAppError(400, "invalid_recharge", "rechargeCoins must be greater than 0") - } - if req.SysOrigin == "" { - return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required") - } - - bundle, err := s.loadBundle(ctx, req.SysOrigin) - if err != nil { - return nil, err - } - payTime, err := parseFlexibleTime(req.PayTime) - if err != nil { - return nil, NewAppError(400, "invalid_pay_time", err.Error()) - } - monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone)) - inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) - validThreshold := effectiveValidThreshold(inviteeRechargeRules) - - var ( - relation model.InviteCampaignRelation - progress model.InviteCampaignInviteeProgress - crossedRules []model.InviteCampaignRewardRule - oldRewardedIDs map[int64]struct{} - hasRelation bool - eligible bool - becameValid bool - inviterUserID int64 - relationID int64 - newTotal int64 - ) - - err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.InviteCampaignRechargeEvent - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("order_id = ?", req.OrderID). - First(&existing).Error - if err == nil { - return errAlreadyProcessed - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("sys_origin = ? AND invitee_user_id = ?", req.SysOrigin, req.UserID). - First(&relation).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - hasRelation = err == nil - eligible = hasRelation && relation.EligibleStatus == relationStatusEligible - - eventID, idErr := util.NextID() - if idErr != nil { - return idErr - } - rechargeEvent := model.InviteCampaignRechargeEvent{ - ID: eventID, - SysOrigin: req.SysOrigin, - OrderID: req.OrderID, - InviteeUserID: req.UserID, - RechargeCoins: req.RechargeCoins, - PayTime: payTime, - CreateTime: time.Now(), - UpdateTime: time.Now(), - } - if hasRelation { - rechargeEvent.InviterUserID = refInt64(relation.InviterUserID) - } - if err := tx.Create(&rechargeEvent).Error; err != nil { - return err - } - if !hasRelation { - return nil - } - - err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("relation_id = ?", relation.ID). - First(&progress).Error - if err != nil { - return err - } - - oldTotal := progress.TotalRecharge - newTotal = oldTotal + req.RechargeCoins - oldRewardedIDs = parseRewardedRuleIDs(progress.RewardedRuleIDs) - crossedRules = crossedInviteeRechargeRules(inviteeRechargeRules, oldTotal, newTotal, oldRewardedIDs) - progress.TotalRecharge = newTotal - progress.UpdateTime = time.Now() - if eligible && !progress.ValidUser && newTotal >= validThreshold { - progress.ValidUser = true - progress.ValidTime = refTime(payTime) - becameValid = true - } - if err := tx.Save(&progress).Error; err != nil { - return err - } - if eligible { - if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { - item.TotalRecharge += req.RechargeCoins - if becameValid { - item.ValidUserCount += 1 - } - }); err != nil { - return err - } - } - - inviterUserID = relation.InviterUserID - relationID = relation.ID - return nil - }) - if err != nil { - if errors.Is(err, errAlreadyProcessed) { - return &RechargeSuccessResponse{ - Processed: true, - AlreadyProcessed: true, - }, nil - } - return nil, err - } - if !hasRelation { - return &RechargeSuccessResponse{ - Processed: true, - HasInviteRelation: false, - }, nil - } - - successfulRuleIDs := make([]int64, 0, len(crossedRules)) - if eligible { - for _, rule := range crossedRules { - _, err := s.dispatchReward(ctx, rewardDispatchInput{ - SysOrigin: req.SysOrigin, - MonthKey: monthKey, - RelationID: refInt64(relationID), - InviterUserID: refInt64(inviterUserID), - InviteeUserID: refInt64(req.UserID), - TargetUserID: inviterUserID, - Reward: rewardFromRule(&rule), - Scene: ruleTypeInviteeRecharge, - RuleID: refInt64(rule.ID), - BusinessKey: fmt.Sprintf("recharge:%d:%d", relationID, rule.ID), - GoldOrigin: mapGoldOrigin(ruleTypeInviteeRecharge), - PropsOrigin: mapPropsOrigin(ruleTypeInviteeRecharge), - Remark: "resident invite recharge reward", - }) - if err != nil { - return nil, err - } - successfulRuleIDs = append(successfulRuleIDs, rule.ID) - } - if len(successfulRuleIDs) > 0 { - if err := s.mergeRewardedRuleIDs(ctx, relationID, successfulRuleIDs); err != nil { - return nil, err - } - } - } - - return &RechargeSuccessResponse{ - Processed: true, - AlreadyProcessed: false, - HasInviteRelation: true, - Eligible: eligible, - RelationID: relationID, - InviterUserID: inviterUserID, - TotalRechargeCoins: newTotal, - ValidUser: progress.ValidUser, - NewlyRewardedRuleIDs: successfulRuleIDs, - }, nil -} - -func (s *InviteService) loadBundle(ctx context.Context, sysOrigin string) (*campaignBundle, error) { - sysOrigin = normalizeSysOrigin(sysOrigin) - cacheKey := "invite:campaign:config:" + sysOrigin - if raw, err := s.repo.Redis.Get(ctx, cacheKey).Result(); err == nil && raw != "" { - var bundle campaignBundle - if jsonErr := json.Unmarshal([]byte(raw), &bundle); jsonErr == nil { - return &bundle, nil - } - } - - bundle := &campaignBundle{ - Config: model.InviteCampaignConfig{ - SysOrigin: sysOrigin, - Enabled: false, - Timezone: defaultTimezone, - AntiCheatSameIPOnce: true, - AntiCheatSameDeviceOnce: true, - }, - } - var configEntity model.InviteCampaignConfig - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", sysOrigin). - First(&configEntity).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if err == nil { - bundle.Config = configEntity - if strings.TrimSpace(bundle.Config.Timezone) == "" { - bundle.Config.Timezone = defaultTimezone - } - } - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", sysOrigin). - Order("sort asc, threshold_value asc, id asc"). - Find(&bundle.Rules).Error; err != nil { - return nil, err - } - payload, _ := json.Marshal(bundle) - s.repo.Redis.Set(ctx, cacheKey, payload, 0) - return bundle, nil -} - -func (s *InviteService) ensureInviteCode(ctx context.Context, user AuthUser) (string, error) { - if mapping, err := s.findInviteCodeMappingByUserID(ctx, user.UserID); err == nil { - return mapping.InviteCode, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return "", err - } - if user.Authorization == "" { - return "", NewAppError(401, "missing_authorization", "authorization is required to generate invite code") - } - resp, err := s.java.EnsureInviteCode(ctx, user.Authorization) - if err != nil { - return "", err - } - info, infoErr := s.findUserBaseInfo(ctx, user.UserID) - if infoErr != nil { - return "", infoErr - } - mapping := model.InviteCodeMapping{ - UserID: user.UserID, - InviteCode: resp.InviteCode, - Account: info.Account, - } - if err := s.repo.DB.WithContext(ctx). - Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"invite_code", "account"}), - }). - Create(&mapping).Error; err != nil { - return "", err - } - return resp.InviteCode, nil -} - -func (s *InviteService) findInviteCodeMappingByUserID(ctx context.Context, userID int64) (*model.InviteCodeMapping, error) { - var mapping model.InviteCodeMapping - err := s.repo.DB.WithContext(ctx). - Where("user_id = ?", userID). - First(&mapping).Error - if err != nil { - return nil, err - } - return &mapping, nil -} - -func (s *InviteService) findInviteCodeMappingByCode(ctx context.Context, inviteCode string) (*model.InviteCodeMapping, error) { - var mapping model.InviteCodeMapping - err := s.repo.DB.WithContext(ctx). - Where("invite_code = ?", inviteCode). - First(&mapping).Error - if err != nil { - return nil, err - } - return &mapping, nil -} - -func (s *InviteService) findUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) { - var info model.UserBaseInfo - err := s.repo.DB.WithContext(ctx). - Where("id = ?", userID). - First(&info).Error - if err != nil { - return nil, err - } - return &info, nil -} - -func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin string, inviteeUserID int64) (*model.InviteCampaignRelation, error) { - var relation model.InviteCampaignRelation - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND invitee_user_id = ?", sysOrigin, inviteeUserID). - First(&relation).Error - if err != nil { - return nil, err - } - return &relation, nil -} - -func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error { - keys = uniqueSortedStrings(keys) - released := make([]string, 0, len(keys)) - for _, key := range keys { - acquired, err := s.acquireBindLock(ctx, key, 3*time.Second) - if err != nil { - s.releaseBindLocks(ctx, released) - return err - } - if !acquired { - s.releaseBindLocks(ctx, released) - return NewAppError(409, "bind_in_progress", "invite bind is in progress") - } - released = append(released, key) - } - defer s.releaseBindLocks(ctx, released) - return fn() -} - -func (s *InviteService) acquireBindLock(ctx context.Context, key string, ttl time.Duration) (bool, error) { - deadline := time.Now().Add(2 * time.Second) - for { - acquired, err := s.repo.Redis.SetNX(ctx, key, "1", ttl).Result() - if err != nil { - return false, err - } - if acquired { - return true, nil - } - if time.Now().After(deadline) { - return false, nil - } - select { - case <-ctx.Done(): - return false, ctx.Err() - case <-time.After(50 * time.Millisecond): - } - } -} - -func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) { - if len(keys) == 0 { - return - } - _ = s.repo.Redis.Del(ctx, keys...).Err() -} - -func uniqueSortedStrings(values []string) []string { - if len(values) == 0 { - return nil - } - seen := make(map[string]struct{}, len(values)) - result := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - result = append(result, value) - } - sort.Strings(result) - return result -} - -func isInviteRelationDuplicateErr(err error) bool { - if err == nil { - return false - } - message := strings.ToLower(strings.TrimSpace(err.Error())) - return strings.Contains(message, "duplicate entry") && strings.Contains(message, "invite_campaign_relation") -} - -func (s *InviteService) existsEligibleRelationByIP(ctx context.Context, sysOrigin, bindIP string) (bool, error) { - var count int64 - err := s.repo.DB.WithContext(ctx). - Model(&model.InviteCampaignRelation{}). - Where("sys_origin = ? AND bind_ip = ? AND eligible_status = ?", sysOrigin, bindIP, relationStatusEligible). - Count(&count).Error - return count > 0, err -} - -func (s *InviteService) existsEligibleRelationByDevice(ctx context.Context, sysOrigin, fingerprint string) (bool, error) { - var count int64 - err := s.repo.DB.WithContext(ctx). - Model(&model.InviteCampaignRelation{}). - Where("sys_origin = ? AND device_fingerprint = ? AND eligible_status = ?", sysOrigin, fingerprint, relationStatusEligible). - Count(&count).Error - return count > 0, err -} - -func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) { - var progress model.InviteCampaignMonthlyProgress - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). - First(&progress).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return &model.InviteCampaignMonthlyProgress{ - SysOrigin: sysOrigin, - InviterUserID: inviterUserID, - MonthKey: monthKey, - }, nil - } - if err != nil { - return nil, err - } - return &progress, nil -} - -func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviterUserID int64) (InviteStats, error) { - stats := InviteStats{} - if err := s.repo.DB.WithContext(ctx). - Model(&model.InviteCampaignRelation{}). - Where("sys_origin = ? AND inviter_user_id = ? AND eligible_status = ?", sysOrigin, inviterUserID, relationStatusEligible). - Count(&stats.TotalInvitedUsers).Error; err != nil { - return stats, err - } - if err := s.repo.DB.WithContext(ctx).Raw(` - SELECT COALESCE(COUNT(1), 0) - FROM invite_campaign_invitee_progress ip - INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id - WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? AND ip.valid_user = 1 - `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalValidUsers).Error; err != nil { - return stats, err - } - if err := s.repo.DB.WithContext(ctx).Raw(` - SELECT COALESCE(SUM(ip.total_recharge_coins), 0) - FROM invite_campaign_invitee_progress ip - INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id - WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? - `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalRechargeCoins).Error; err != nil { - return stats, err - } - if err := s.repo.DB.WithContext(ctx).Raw(` - SELECT COALESCE(SUM(gold_amount), 0) - FROM invite_campaign_reward_log - WHERE sys_origin = ? AND inviter_user_id = ? AND target_user_id = ? AND status = ? - `, sysOrigin, inviterUserID, inviterUserID, rewardLogStatusSuccess).Scan(&stats.RewardGoldCoins).Error; err != nil { - return stats, err - } - return stats, nil -} - -func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) { - var claims []model.InviteCampaignTaskClaim - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). - Find(&claims).Error; err != nil { - return nil, err - } - result := make(map[int64]bool, len(claims)) - for _, item := range claims { - result[item.RuleID] = true - } - return result, nil -} - -func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64) (bool, error) { - var count int64 - err := s.repo.DB.WithContext(ctx). - Model(&model.InviteCampaignTaskClaim{}). - Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). - Count(&count).Error - return count > 0, err -} - -func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error { - var progress model.InviteCampaignMonthlyProgress - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). - First(&progress).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - id, idErr := util.NextID() - if idErr != nil { - return idErr - } - now := time.Now() - progress = model.InviteCampaignMonthlyProgress{ - ID: id, - SysOrigin: sysOrigin, - InviterUserID: inviterUserID, - MonthKey: monthKey, - CreateTime: now, - UpdateTime: now, - } - mutate(&progress) - return tx.Create(&progress).Error - } - if err != nil { - return err - } - mutate(&progress) - progress.UpdateTime = time.Now() - return tx.Save(&progress).Error -} - -func (s *InviteService) createTaskClaimIfAbsentTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64, claimTime time.Time) error { - var existing model.InviteCampaignTaskClaim - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). - First(&existing).Error - if err == nil { - return nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - id, idErr := util.NextID() - if idErr != nil { - return idErr - } - return tx.Create(&model.InviteCampaignTaskClaim{ - ID: id, - SysOrigin: sysOrigin, - InviterUserID: inviterUserID, - MonthKey: monthKey, - RuleID: ruleID, - ClaimTime: claimTime, - CreateTime: claimTime, - UpdateTime: claimTime, - }).Error -} - -func (s *InviteService) dispatchReward(ctx context.Context, input rewardDispatchInput) (*rewardDispatchResult, error) { - if input.Reward.GoldAmount <= 0 && input.Reward.RewardGroupID == nil { - return &rewardDispatchResult{Success: true}, nil - } - if input.Reward.GoldAmount > 0 { - if err := s.dispatchGold(ctx, input); err != nil { - return &rewardDispatchResult{Success: false}, err - } - } - if input.Reward.RewardGroupID != nil { - if err := s.dispatchProps(ctx, input); err != nil { - return &rewardDispatchResult{Success: false}, err - } - } - return &rewardDispatchResult{Success: true}, nil -} - -func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchInput) error { - businessKey := input.BusinessKey + ":gold" - logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, input.Reward.GoldAmount, nil) - if err != nil { - return err - } - if logRecord.Status == rewardLogStatusSuccess { - return nil - } - - err = s.java.GrantGold(ctx, integration.GrantGoldRequest{ - UserID: input.TargetUserID, - SysOrigin: input.SysOrigin, - EventID: businessKey, - Origin: input.GoldOrigin, - GoldAmount: input.Reward.GoldAmount, - Remark: input.Remark, - }) - if err != nil { - return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) - } - if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil { - return err - } - if input.MonthKey != "" && input.InviterUserID != nil && *input.InviterUserID == input.TargetUserID { - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return s.incrementMonthlyProgressTx(tx, input.SysOrigin, input.TargetUserID, input.MonthKey, func(item *model.InviteCampaignMonthlyProgress) { - item.RewardGoldCoins += input.Reward.GoldAmount - }) - }) - } - return nil -} - -func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchInput) error { - if input.Reward.RewardGroupID == nil { - return nil - } - businessKey := input.BusinessKey + ":props" - logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, 0, input.Reward.RewardGroupID) - if err != nil { - return err - } - if logRecord.Status == rewardLogStatusSuccess { - return nil - } - err = s.java.GrantProps(ctx, integration.GrantPropsRequest{ - TrackID: logRecord.ID, - AcceptUserID: input.TargetUserID, - SysOrigin: input.SysOrigin, - Origin: input.PropsOrigin, - SourceGroupID: *input.Reward.RewardGroupID, - }) - if err != nil { - return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) - } - return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "") -} - -func (s *InviteService) getOrCreateRewardLog(ctx context.Context, businessKey string, input rewardDispatchInput, goldAmount int64, rewardGroupID *int64) (*model.InviteCampaignRewardLog, error) { - var record model.InviteCampaignRewardLog - err := s.repo.DB.WithContext(ctx). - Where("business_key = ?", businessKey). - First(&record).Error - if err == nil { - return &record, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - id, idErr := util.NextID() - if idErr != nil { - return nil, idErr - } - now := time.Now() - record = model.InviteCampaignRewardLog{ - ID: id, - SysOrigin: input.SysOrigin, - RelationID: input.RelationID, - InviterUserID: input.InviterUserID, - InviteeUserID: input.InviteeUserID, - TargetUserID: input.TargetUserID, - RuleID: input.RuleID, - RewardScene: input.Scene, - BusinessKey: businessKey, - GoldAmount: goldAmount, - RewardGroupID: rewardGroupID, - Status: rewardLogStatusPending, - CreateTime: now, - UpdateTime: now, - } - if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate") { - return nil, err - } - if err := s.repo.DB.WithContext(ctx).Where("business_key = ?", businessKey).First(&record).Error; err != nil { - return nil, err - } - } - return &record, nil -} - -func (s *InviteService) updateRewardLogStatus(ctx context.Context, id int64, status, message string) error { - return s.repo.DB.WithContext(ctx). - Model(&model.InviteCampaignRewardLog{}). - Where("id = ?", id). - Updates(map[string]any{ - "status": status, - "error_message": truncate(message, 255), - "update_time": time.Now(), - }).Error -} - -func (s *InviteService) mergeRewardedRuleIDs(ctx context.Context, relationID int64, ruleIDs []int64) error { - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var progress model.InviteCampaignInviteeProgress - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("relation_id = ?", relationID). - First(&progress).Error; err != nil { - return err - } - current := parseRewardedRuleIDs(progress.RewardedRuleIDs) - for _, ruleID := range ruleIDs { - current[ruleID] = struct{}{} - } - progress.RewardedRuleIDs = formatRewardedRuleIDs(current) - progress.UpdateTime = time.Now() - return tx.Save(&progress).Error - }) -} - -func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView { - result := make([]TaskView, 0, len(rules)) - for _, rule := range rules { - if !rule.Enabled { - continue - } - result = append(result, TaskView{ - RuleID: rule.ID, - RuleType: rule.RuleType, - Threshold: rule.ThresholdValue, - Progress: progress, - GoldAmount: rule.GoldAmount, - RewardGroupID: rule.RewardGroupID, - Achieved: progress >= rule.ThresholdValue, - Claimed: claimed[rule.ID], - }) - } - return result -} - -func firstRule(rules []model.InviteCampaignRewardRule, ruleType string) *model.InviteCampaignRewardRule { - for _, item := range rules { - if item.RuleType == ruleType && item.Enabled { - rule := item - return &rule - } - } - return nil -} - -func rulesByType(rules []model.InviteCampaignRewardRule, ruleType string) []model.InviteCampaignRewardRule { - result := make([]model.InviteCampaignRewardRule, 0) - for _, item := range rules { - if item.RuleType == ruleType && item.Enabled { - result = append(result, item) - } - } - sort.SliceStable(result, func(i, j int) bool { - if result[i].Sort == result[j].Sort { - if result[i].ThresholdValue == result[j].ThresholdValue { - return result[i].ID < result[j].ID - } - return result[i].ThresholdValue < result[j].ThresholdValue - } - return result[i].Sort < result[j].Sort - }) - return result -} - -func findRuleByID(rules []model.InviteCampaignRewardRule, ruleID int64) *model.InviteCampaignRewardRule { - for _, item := range rules { - if item.ID == ruleID { - rule := item - return &rule - } - } - return nil -} - -func crossedInviteeRechargeRules(rules []model.InviteCampaignRewardRule, oldTotal, newTotal int64, rewarded map[int64]struct{}) []model.InviteCampaignRewardRule { - result := make([]model.InviteCampaignRewardRule, 0) - for _, rule := range rules { - if !rule.Enabled { - continue - } - if _, exists := rewarded[rule.ID]; exists { - continue - } - if oldTotal < rule.ThresholdValue && newTotal >= rule.ThresholdValue { - result = append(result, rule) - } - } - return result -} - -func rewardFromRule(rule *model.InviteCampaignRewardRule) RewardView { - if rule == nil { - return RewardView{} - } - return RewardView{ - RuleID: rule.ID, - RuleType: rule.RuleType, - Threshold: rule.ThresholdValue, - GoldAmount: rule.GoldAmount, - RewardGroupID: rule.RewardGroupID, - } -} - -func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView { - result := make([]RewardView, 0, len(rules)) - for _, item := range rules { - result = append(result, rewardFromRule(&item)) - } - return result -} - -func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 { - var threshold int64 - for _, rule := range rules { - if !rule.Enabled || rule.ThresholdValue <= 0 { - continue - } - if threshold == 0 || rule.ThresholdValue < threshold { - threshold = rule.ThresholdValue - } - } - if threshold == 0 { - return defaultValidRechargeCoins - } - return threshold -} - -func parseRewardedRuleIDs(raw string) map[int64]struct{} { - result := make(map[int64]struct{}) - for _, item := range strings.Split(raw, ",") { - item = strings.TrimSpace(item) - if item == "" { - continue - } - value, err := strconv.ParseInt(item, 10, 64) - if err == nil && value > 0 { - result[value] = struct{}{} - } - } - return result -} - -func formatRewardedRuleIDs(values map[int64]struct{}) string { - items := make([]int64, 0, len(values)) - for value := range values { - items = append(items, value) - } - sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) - parts := make([]string, 0, len(items)) - for _, value := range items { - parts = append(parts, strconv.FormatInt(value, 10)) - } - return strings.Join(parts, ",") -} - -func parseFlexibleTime(value any) (time.Time, error) { - switch v := value.(type) { - case nil: - return time.Now(), nil - case float64: - return fromUnixNumber(int64(v)), nil - case int64: - return fromUnixNumber(v), nil - case int: - return fromUnixNumber(int64(v)), nil - case json.Number: - num, err := v.Int64() - if err != nil { - return time.Time{}, err - } - return fromUnixNumber(num), nil - case string: - v = strings.TrimSpace(v) - if v == "" { - return time.Now(), nil - } - if digitsOnly(v) { - num, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return time.Time{}, err - } - return fromUnixNumber(num), nil - } - layouts := []string{ - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02T15:04:05", - "2006-01-02", - } - for _, layout := range layouts { - if parsed, err := time.Parse(layout, v); err == nil { - return parsed, nil - } - } - return time.Time{}, fmt.Errorf("unsupported payTime format") - default: - return time.Time{}, fmt.Errorf("unsupported payTime type") - } -} - -func fromUnixNumber(value int64) time.Time { - if value > 1_000_000_000_000 { - return time.UnixMilli(value) - } - return time.Unix(value, 0) -} - -func normalizeSysOrigin(value string) string { - return strings.TrimSpace(value) -} - -func normalizeTimezone(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return defaultTimezone - } - return value -} - -func monthKeyAt(t time.Time, timezone string) string { - location, err := time.LoadLocation(normalizeTimezone(timezone)) - if err != nil { - location = time.FixedZone(defaultTimezone, 3*3600) - } - return t.In(location).Format("200601") -} - -func digitsOnly(value string) bool { - for _, ch := range value { - if ch < '0' || ch > '9' { - return false - } - } - return true -} - -func truncate(value string, size int) string { - if len(value) <= size { - return value - } - return value[:size] -} - -func optionalRuleID(rule *model.InviteCampaignRewardRule) *int64 { - if rule == nil || rule.ID == 0 { - return nil - } - return refInt64(rule.ID) -} - -func refInt64(value int64) *int64 { - return &value -} - -func refTime(value time.Time) *time.Time { - return &value -} - -func mapGoldOrigin(ruleType string) string { - switch ruleType { - case ruleTypeInviterBind: - return "INVITED_NEW_USER_REWARD" - case ruleTypeInviteeRecharge: - return "INVITED_USER_FIRST_RECHARGE_REWARD" - case ruleTypeInviterTotalRecharge: - return "CUMULATIVE_RECHARGE_REWARDS" - default: - return "INVITE_USER_REWARDS" - } -} - -func mapPropsOrigin(ruleType string) string { - switch ruleType { - case ruleTypeInviterTotalRecharge: - return "CUMULATIVE_RECHARGE_REWARDS" - default: - return "INVITE_USER_REWARDS" - } -} diff --git a/internal/service/invite/bind.go b/internal/service/invite/bind.go new file mode 100644 index 0000000..7282c7e --- /dev/null +++ b/internal/service/invite/bind.go @@ -0,0 +1,314 @@ +package invite + +import ( + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +// BindCode 处理邀请码绑定,并写入邀请关系、风控状态和双方即时奖励。 +func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) { + inviteCode := strings.TrimSpace(req.InviteCode) + if inviteCode == "" { + return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") + } + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + if !bundle.Config.Enabled { + return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled") + } + + mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(404, "invite_code_not_found", "invite code not found") + } + return nil, err + } + if mapping.UserID == user.UserID { + return nil, NewAppError(400, "self_bind_not_allowed", "cannot bind your own invite code") + } + + inviterProfile, err := s.findUserBaseInfo(ctx, mapping.UserID) + if err != nil { + return nil, err + } + if inviterProfile.OriginSys != "" && inviterProfile.OriginSys != user.SysOrigin { + return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system") + } + + fingerprint := "" + if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { + fingerprint = strings.TrimSpace(value) + } + // 绑码链路同时锁住用户以及可选的 IP/设备维度,避免并发穿透风控。 + lockKeys := []string{ + fmt.Sprintf("invite:bind:lock:user:%s:%d", user.SysOrigin, user.UserID), + } + if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { + lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:ip:%s:%s", user.SysOrigin, strings.TrimSpace(clientIP))) + } + if bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { + lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:device:%s:%s", user.SysOrigin, fingerprint)) + } + + var ( + relationID int64 + eligibleStatus = relationStatusEligible + blockReason string + monthKey string + ) + err = s.withBindLocks(ctx, lockKeys, func() error { + if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { + return NewAppError(400, "already_bound", "invite code already bound") + } else if !errors.Is(lockErr, gorm.ErrRecordNotFound) { + return lockErr + } + + // 在锁内判定风控资格并写入关系、进度两张表。 + bindTime := time.Now() + monthKey = monthKeyAt(bindTime, normalizeTimezone(bundle.Config.Timezone)) + if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { + exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP) + if existsErr != nil { + return existsErr + } + if exists { + eligibleStatus = relationStatusBlocked + blockReason = blockReasonDuplicateIP + } + } + if eligibleStatus == relationStatusEligible && bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { + exists, existsErr := s.existsEligibleRelationByDevice(ctx, user.SysOrigin, fingerprint) + if existsErr != nil { + return existsErr + } + if exists { + eligibleStatus = relationStatusBlocked + blockReason = blockReasonDuplicateDevice + } + } + + nextRelationID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + progressID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + relationID = nextRelationID + relation := model.InviteCampaignRelation{ + ID: relationID, + SysOrigin: user.SysOrigin, + InviterUserID: mapping.UserID, + InviteeUserID: user.UserID, + InviteCode: inviteCode, + BindIP: strings.TrimSpace(clientIP), + DeviceFingerprint: fingerprint, + EligibleStatus: eligibleStatus, + BlockReason: blockReason, + BindTime: bindTime, + CreateTime: bindTime, + UpdateTime: bindTime, + } + progress := model.InviteCampaignInviteeProgress{ + ID: progressID, + RelationID: relationID, + SysOrigin: user.SysOrigin, + InviterUserID: mapping.UserID, + InviteeUserID: user.UserID, + TotalRecharge: 0, + ValidUser: false, + RewardedRuleIDs: "", + CreateTime: bindTime, + UpdateTime: bindTime, + } + + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&relation).Error; err != nil { + if isInviteRelationDuplicateErr(err) { + return NewAppError(400, "already_bound", "invite code already bound") + } + return err + } + if err := tx.Create(&progress).Error; err != nil { + return err + } + if eligibleStatus == relationStatusEligible { + if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.InviteCount += 1 + }); err != nil { + return err + } + } + return nil + }) + }) + if err != nil { + return nil, err + } + + inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + // 只有资格正常的绑定关系才会触发双方即时奖励。 + if eligibleStatus == relationStatusEligible { + inviterUserID := mapping.UserID + inviteeUserID := user.UserID + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + MonthKey: monthKey, + RelationID: refInt64(relationID), + InviterUserID: &inviterUserID, + InviteeUserID: &inviteeUserID, + TargetUserID: mapping.UserID, + Reward: rewardFromRule(inviterBindRule), + Scene: ruleTypeInviterBind, + RuleID: optionalRuleID(inviterBindRule), + BusinessKey: fmt.Sprintf("bind:inviter:%d", relationID), + GoldOrigin: "INVITED_NEW_USER_REWARD", + PropsOrigin: "INVITE_USER_REWARDS", + Remark: "resident invite inviter bind reward", + }) + if err != nil { + return nil, err + } + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + RelationID: refInt64(relationID), + InviterUserID: &inviterUserID, + InviteeUserID: &inviteeUserID, + TargetUserID: user.UserID, + Reward: rewardFromRule(inviteeBindRule), + Scene: ruleTypeInviteeBind, + RuleID: optionalRuleID(inviteeBindRule), + BusinessKey: fmt.Sprintf("bind:invitee:%d", relationID), + GoldOrigin: "INVITE_USER_REWARDS", + PropsOrigin: "INVITE_USER_REWARDS", + Remark: "resident invite invitee bind reward", + }) + if err != nil { + return nil, err + } + } + + return &BindCodeResponse{ + RelationID: relationID, + Eligible: eligibleStatus == relationStatusEligible, + BlockReason: blockReason, + InviterUserID: mapping.UserID, + InviteCode: inviteCode, + InviterReward: rewardFromRule(inviterBindRule), + InviteeReward: rewardFromRule(inviteeBindRule), + }, nil +} + +// withBindLocks 为绑码流程批量加锁,确保同用户/同邀请码并发安全。 +func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error { + keys = uniqueSortedStrings(keys) + released := make([]string, 0, len(keys)) + for _, key := range keys { + acquired, err := s.acquireBindLock(ctx, key, 3*time.Second) + if err != nil { + s.releaseBindLocks(ctx, released) + return err + } + if !acquired { + s.releaseBindLocks(ctx, released) + return NewAppError(409, "bind_in_progress", "invite bind is in progress") + } + released = append(released, key) + } + defer s.releaseBindLocks(ctx, released) + return fn() +} + +// acquireBindLock 获取单个绑码锁。 +func (s *InviteService) acquireBindLock(ctx context.Context, key string, ttl time.Duration) (bool, error) { + deadline := time.Now().Add(2 * time.Second) + for { + acquired, err := s.repo.Redis.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + return false, err + } + if acquired { + return true, nil + } + if time.Now().After(deadline) { + return false, nil + } + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +// releaseBindLocks 释放绑码流程持有的锁。 +func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) { + if len(keys) == 0 { + return + } + _ = s.repo.Redis.Del(ctx, keys...).Err() +} + +// uniqueSortedStrings 去重并排序字符串切片。 +func uniqueSortedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result +} + +// isInviteRelationDuplicateErr 判断是否是邀请关系唯一键冲突。 +func isInviteRelationDuplicateErr(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(message, "duplicate entry") && strings.Contains(message, "invite_campaign_relation") +} + +// existsEligibleRelationByIP 判断同 IP 是否已经存在有效邀请关系。 +func (s *InviteService) existsEligibleRelationByIP(ctx context.Context, sysOrigin, bindIP string) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND bind_ip = ? AND eligible_status = ?", sysOrigin, bindIP, relationStatusEligible). + Count(&count).Error + return count > 0, err +} + +// existsEligibleRelationByDevice 判断同设备是否已经存在有效邀请关系。 +func (s *InviteService) existsEligibleRelationByDevice(ctx context.Context, sysOrigin, fingerprint string) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND device_fingerprint = ? AND eligible_status = ?", sysOrigin, fingerprint, relationStatusEligible). + Count(&count).Error + return count > 0, err +} diff --git a/internal/service/invite/home.go b/internal/service/invite/home.go new file mode 100644 index 0000000..5abbb5a --- /dev/null +++ b/internal/service/invite/home.go @@ -0,0 +1,172 @@ +package invite + +import ( + "chatapp3-golang/internal/model" + "context" + "net/url" + "sort" + "time" +) + +// GetHome 返回邀请活动首页所需的配置、任务、月度进度和累计统计。 +func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + inviteCode, err := s.ensureInviteCode(ctx, user) + if err != nil { + return nil, err + } + + // 首页需要同时返回当前月进度、累计统计、邀请码和任务配置。 + now := time.Now() + timezone := normalizeTimezone(bundle.Config.Timezone) + monthKey := monthKeyAt(now, timezone) + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + stats, err := s.loadStats(ctx, user.SysOrigin, user.UserID) + if err != nil { + return nil, err + } + claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + + inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) + validCountRules := rulesByType(bundle.Rules, ruleTypeInviterValidCount) + totalRechargeRules := rulesByType(bundle.Rules, ruleTypeInviterTotalRecharge) + + resp := &HomeResponse{ + CampaignEnabled: bundle.Config.Enabled, + SysOrigin: user.SysOrigin, + InviteCode: inviteCode, + InviteLink: s.cfg.Invite.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(inviteCode), + ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), + ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), + DownloadURL: bundle.Config.DownloadURL, + ServiceContact: bundle.Config.ServiceContact, + Timezone: timezone, + MonthKey: monthKey, + ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), + InviterBindReward: rewardFromRule(inviterBindRule), + InviteeBindReward: rewardFromRule(inviteeBindRule), + InviteeRecharge: rewardsFromRules(inviteeRechargeRules), + Stats: stats, + } + resp.MonthlyProgress.InviteCount = monthly.InviteCount + resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount + resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge + resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins + resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) + resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) + return resp, nil +} + +// buildTaskViews 根据规则、进度和领取状态生成任务展示列表。 +func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView { + result := make([]TaskView, 0, len(rules)) + for _, rule := range rules { + if !rule.Enabled { + continue + } + result = append(result, TaskView{ + RuleID: rule.ID, + RuleType: rule.RuleType, + Threshold: rule.ThresholdValue, + Progress: progress, + GoldAmount: rule.GoldAmount, + RewardGroupID: rule.RewardGroupID, + Achieved: progress >= rule.ThresholdValue, + Claimed: claimed[rule.ID], + }) + } + return result +} + +// firstRule 返回某类规则里的第一条规则。 +func firstRule(rules []model.InviteCampaignRewardRule, ruleType string) *model.InviteCampaignRewardRule { + for _, item := range rules { + if item.RuleType == ruleType && item.Enabled { + rule := item + return &rule + } + } + return nil +} + +// rulesByType 返回某类规则的有序列表。 +func rulesByType(rules []model.InviteCampaignRewardRule, ruleType string) []model.InviteCampaignRewardRule { + result := make([]model.InviteCampaignRewardRule, 0) + for _, item := range rules { + if item.RuleType == ruleType && item.Enabled { + result = append(result, item) + } + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Sort == result[j].Sort { + if result[i].ThresholdValue == result[j].ThresholdValue { + return result[i].ID < result[j].ID + } + return result[i].ThresholdValue < result[j].ThresholdValue + } + return result[i].Sort < result[j].Sort + }) + return result +} + +// findRuleByID 按规则 ID 从规则列表中查找规则。 +func findRuleByID(rules []model.InviteCampaignRewardRule, ruleID int64) *model.InviteCampaignRewardRule { + for _, item := range rules { + if item.ID == ruleID { + rule := item + return &rule + } + } + return nil +} + +// rewardFromRule 把规则转换成对外奖励视图。 +func rewardFromRule(rule *model.InviteCampaignRewardRule) RewardView { + if rule == nil { + return RewardView{} + } + return RewardView{ + RuleID: rule.ID, + RuleType: rule.RuleType, + Threshold: rule.ThresholdValue, + GoldAmount: rule.GoldAmount, + RewardGroupID: rule.RewardGroupID, + } +} + +// rewardsFromRules 把规则列表转换成奖励视图列表。 +func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView { + result := make([]RewardView, 0, len(rules)) + for _, item := range rules { + result = append(result, rewardFromRule(&item)) + } + return result +} + +// effectiveValidThreshold 计算“有效用户”判定使用的最低充值门槛。 +func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 { + var threshold int64 + for _, rule := range rules { + if !rule.Enabled || rule.ThresholdValue <= 0 { + continue + } + if threshold == 0 || rule.ThresholdValue < threshold { + threshold = rule.ThresholdValue + } + } + if threshold == 0 { + return defaultValidRechargeCoins + } + return threshold +} diff --git a/internal/service/invite/landing.go b/internal/service/invite/landing.go new file mode 100644 index 0000000..ba33cbb --- /dev/null +++ b/internal/service/invite/landing.go @@ -0,0 +1,45 @@ +package invite + +import ( + "context" + "errors" + "strings" + + "gorm.io/gorm" +) + +// GetLanding 返回邀请落地页信息,供被邀请用户查看邀请人及奖励。 +func (s *InviteService) GetLanding(ctx context.Context, inviteCode string) (*LandingResponse, error) { + inviteCode = strings.TrimSpace(inviteCode) + if inviteCode == "" { + return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") + } + mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(404, "invite_code_not_found", "invite code not found") + } + return nil, err + } + inviter, err := s.findUserBaseInfo(ctx, mapping.UserID) + if err != nil { + return nil, err + } + bundle, err := s.loadBundle(ctx, inviter.OriginSys) + if err != nil { + return nil, err + } + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + return &LandingResponse{ + CampaignEnabled: bundle.Config.Enabled, + SysOrigin: inviter.OriginSys, + InviterUserID: inviter.ID, + InviterNickname: inviter.UserNickname, + InviterAvatar: inviter.UserAvatar, + InviteCode: mapping.InviteCode, + DownloadURL: bundle.Config.DownloadURL, + ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), + ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), + InviteeReward: rewardFromRule(inviteeBindRule), + }, nil +} diff --git a/internal/service/invite/normalize.go b/internal/service/invite/normalize.go new file mode 100644 index 0000000..1fd77db --- /dev/null +++ b/internal/service/invite/normalize.go @@ -0,0 +1,55 @@ +package invite + +import ( + "strings" + "time" +) + +// defaultIfBlank 在字符串为空时返回默认值。 +func defaultIfBlank(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// normalizeSysOrigin 统一系统标识格式。 +func normalizeSysOrigin(value string) string { + return strings.TrimSpace(value) +} + +// normalizeTimezone 统一时区配置,缺省回退到默认时区。 +func normalizeTimezone(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return defaultTimezone + } + return value +} + +// monthKeyAt 计算某个时间点在指定时区下的月份标识。 +func monthKeyAt(t time.Time, timezone string) string { + location, err := time.LoadLocation(normalizeTimezone(timezone)) + if err != nil { + location = time.FixedZone(defaultTimezone, 3*3600) + } + return t.In(location).Format("200601") +} + +// digitsOnly 判断字符串是否只包含数字。 +func digitsOnly(value string) bool { + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +// truncate 截断字符串,避免超出数据库字段长度。 +func truncate(value string, size int) string { + if len(value) <= size { + return value + } + return value[:size] +} diff --git a/internal/service/invite/recharge.go b/internal/service/invite/recharge.go new file mode 100644 index 0000000..63786c4 --- /dev/null +++ b/internal/service/invite/recharge.go @@ -0,0 +1,302 @@ +package invite + +import ( + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// HandleRechargeSuccess 处理被邀请人的充值成功事件,并更新进度与触发奖励。 +func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeSuccessRequest) (*RechargeSuccessResponse, error) { + req.OrderID = strings.TrimSpace(req.OrderID) + req.SysOrigin = strings.TrimSpace(req.SysOrigin) + if req.OrderID == "" { + return nil, NewAppError(400, "invalid_order_id", "orderId is required") + } + if req.UserID == 0 { + return nil, NewAppError(400, "invalid_user_id", "userId is required") + } + if req.RechargeCoins <= 0 { + return nil, NewAppError(400, "invalid_recharge", "rechargeCoins must be greater than 0") + } + if req.SysOrigin == "" { + return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required") + } + + bundle, err := s.loadBundle(ctx, req.SysOrigin) + if err != nil { + return nil, err + } + payTime, err := parseFlexibleTime(req.PayTime) + if err != nil { + return nil, NewAppError(400, "invalid_pay_time", err.Error()) + } + monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone)) + inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) + validThreshold := effectiveValidThreshold(inviteeRechargeRules) + + var ( + relation model.InviteCampaignRelation + progress model.InviteCampaignInviteeProgress + crossedRules []model.InviteCampaignRewardRule + oldRewardedIDs map[int64]struct{} + hasRelation bool + eligible bool + becameValid bool + inviterUserID int64 + relationID int64 + newTotal int64 + ) + + // 在事务里完成充值事件幂等、关系查询、进度更新和月度统计累加。 + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.InviteCampaignRechargeEvent + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ?", req.OrderID). + First(&existing).Error + if err == nil { + return errAlreadyProcessed + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND invitee_user_id = ?", req.SysOrigin, req.UserID). + First(&relation).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hasRelation = err == nil + eligible = hasRelation && relation.EligibleStatus == relationStatusEligible + + eventID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + rechargeEvent := model.InviteCampaignRechargeEvent{ + ID: eventID, + SysOrigin: req.SysOrigin, + OrderID: req.OrderID, + InviteeUserID: req.UserID, + RechargeCoins: req.RechargeCoins, + PayTime: payTime, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if hasRelation { + rechargeEvent.InviterUserID = refInt64(relation.InviterUserID) + } + if err := tx.Create(&rechargeEvent).Error; err != nil { + return err + } + if !hasRelation { + return nil + } + + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("relation_id = ?", relation.ID). + First(&progress).Error + if err != nil { + return err + } + + oldTotal := progress.TotalRecharge + newTotal = oldTotal + req.RechargeCoins + oldRewardedIDs = parseRewardedRuleIDs(progress.RewardedRuleIDs) + crossedRules = crossedInviteeRechargeRules(inviteeRechargeRules, oldTotal, newTotal, oldRewardedIDs) + progress.TotalRecharge = newTotal + progress.UpdateTime = time.Now() + if eligible && !progress.ValidUser && newTotal >= validThreshold { + progress.ValidUser = true + progress.ValidTime = refTime(payTime) + becameValid = true + } + if err := tx.Save(&progress).Error; err != nil { + return err + } + if eligible { + if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.TotalRecharge += req.RechargeCoins + if becameValid { + item.ValidUserCount += 1 + } + }); err != nil { + return err + } + } + + inviterUserID = relation.InviterUserID + relationID = relation.ID + return nil + }) + if err != nil { + if errors.Is(err, errAlreadyProcessed) { + return &RechargeSuccessResponse{ + Processed: true, + AlreadyProcessed: true, + }, nil + } + return nil, err + } + if !hasRelation { + return &RechargeSuccessResponse{ + Processed: true, + HasInviteRelation: false, + }, nil + } + + successfulRuleIDs := make([]int64, 0, len(crossedRules)) + // 事务提交后再逐条发奖,避免把外部接口调用放进数据库事务里。 + if eligible { + for _, rule := range crossedRules { + _, err := s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: req.SysOrigin, + MonthKey: monthKey, + RelationID: refInt64(relationID), + InviterUserID: refInt64(inviterUserID), + InviteeUserID: refInt64(req.UserID), + TargetUserID: inviterUserID, + Reward: rewardFromRule(&rule), + Scene: ruleTypeInviteeRecharge, + RuleID: refInt64(rule.ID), + BusinessKey: fmt.Sprintf("recharge:%d:%d", relationID, rule.ID), + GoldOrigin: mapGoldOrigin(ruleTypeInviteeRecharge), + PropsOrigin: mapPropsOrigin(ruleTypeInviteeRecharge), + Remark: "resident invite recharge reward", + }) + if err != nil { + return nil, err + } + successfulRuleIDs = append(successfulRuleIDs, rule.ID) + } + if len(successfulRuleIDs) > 0 { + if err := s.mergeRewardedRuleIDs(ctx, relationID, successfulRuleIDs); err != nil { + return nil, err + } + } + } + + return &RechargeSuccessResponse{ + Processed: true, + AlreadyProcessed: false, + HasInviteRelation: true, + Eligible: eligible, + RelationID: relationID, + InviterUserID: inviterUserID, + TotalRechargeCoins: newTotal, + ValidUser: progress.ValidUser, + NewlyRewardedRuleIDs: successfulRuleIDs, + }, nil +} + +// crossedInviteeRechargeRules 找出本次充值跨过的被邀请人充值奖励规则。 +func crossedInviteeRechargeRules(rules []model.InviteCampaignRewardRule, oldTotal, newTotal int64, rewarded map[int64]struct{}) []model.InviteCampaignRewardRule { + result := make([]model.InviteCampaignRewardRule, 0) + for _, rule := range rules { + if !rule.Enabled { + continue + } + if _, exists := rewarded[rule.ID]; exists { + continue + } + if oldTotal < rule.ThresholdValue && newTotal >= rule.ThresholdValue { + result = append(result, rule) + } + } + return result +} + +// parseRewardedRuleIDs 解析已奖励规则 ID 字符串。 +func parseRewardedRuleIDs(raw string) map[int64]struct{} { + result := make(map[int64]struct{}) + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + value, err := strconv.ParseInt(item, 10, 64) + if err == nil && value > 0 { + result[value] = struct{}{} + } + } + return result +} + +// formatRewardedRuleIDs 把规则 ID 集合格式化成稳定字符串。 +func formatRewardedRuleIDs(values map[int64]struct{}) string { + items := make([]int64, 0, len(values)) + for value := range values { + items = append(items, value) + } + sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) + parts := make([]string, 0, len(items)) + for _, value := range items { + parts = append(parts, strconv.FormatInt(value, 10)) + } + return strings.Join(parts, ",") +} + +// parseFlexibleTime 宽松解析回调里的时间字段。 +func parseFlexibleTime(value any) (time.Time, error) { + switch v := value.(type) { + case nil: + return time.Now(), nil + case float64: + return fromUnixNumber(int64(v)), nil + case int64: + return fromUnixNumber(v), nil + case int: + return fromUnixNumber(int64(v)), nil + case json.Number: + num, err := v.Int64() + if err != nil { + return time.Time{}, err + } + return fromUnixNumber(num), nil + case string: + v = strings.TrimSpace(v) + if v == "" { + return time.Now(), nil + } + if digitsOnly(v) { + num, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, err + } + return fromUnixNumber(num), nil + } + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, v); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported payTime format") + default: + return time.Time{}, fmt.Errorf("unsupported payTime type") + } +} + +// fromUnixNumber 把秒或毫秒级时间戳转换成时间。 +func fromUnixNumber(value int64) time.Time { + if value > 1_000_000_000_000 { + return time.UnixMilli(value) + } + return time.Unix(value, 0) +} diff --git a/internal/service/invite/reward.go b/internal/service/invite/reward.go new file mode 100644 index 0000000..1fb17f0 --- /dev/null +++ b/internal/service/invite/reward.go @@ -0,0 +1,210 @@ +package invite + +import ( + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// dispatchReward 根据 RewardView 类型发放金币或道具奖励。 +func (s *InviteService) dispatchReward(ctx context.Context, input rewardDispatchInput) (*rewardDispatchResult, error) { + if input.Reward.GoldAmount <= 0 && input.Reward.RewardGroupID == nil { + return &rewardDispatchResult{Success: true}, nil + } + if input.Reward.GoldAmount > 0 { + if err := s.dispatchGold(ctx, input); err != nil { + return &rewardDispatchResult{Success: false}, err + } + } + if input.Reward.RewardGroupID != nil { + if err := s.dispatchProps(ctx, input); err != nil { + return &rewardDispatchResult{Success: false}, err + } + } + return &rewardDispatchResult{Success: true}, nil +} + +// dispatchGold 调用钱包接口发放金币奖励。 +func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchInput) error { + businessKey := input.BusinessKey + ":gold" + logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, input.Reward.GoldAmount, nil) + if err != nil { + return err + } + if logRecord.Status == rewardLogStatusSuccess { + return nil + } + + err = s.java.GrantGold(ctx, integration.GrantGoldRequest{ + UserID: input.TargetUserID, + SysOrigin: input.SysOrigin, + EventID: businessKey, + Origin: input.GoldOrigin, + GoldAmount: input.Reward.GoldAmount, + Remark: input.Remark, + }) + if err != nil { + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + } + if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil { + return err + } + if input.MonthKey != "" && input.InviterUserID != nil && *input.InviterUserID == input.TargetUserID { + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.incrementMonthlyProgressTx(tx, input.SysOrigin, input.TargetUserID, input.MonthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.RewardGoldCoins += input.Reward.GoldAmount + }) + }) + } + return nil +} + +// dispatchProps 调用道具奖励接口发放道具奖励。 +func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchInput) error { + if input.Reward.RewardGroupID == nil { + return nil + } + businessKey := input.BusinessKey + ":props" + logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, 0, input.Reward.RewardGroupID) + if err != nil { + return err + } + if logRecord.Status == rewardLogStatusSuccess { + return nil + } + err = s.java.GrantProps(ctx, integration.GrantPropsRequest{ + TrackID: logRecord.ID, + AcceptUserID: input.TargetUserID, + SysOrigin: input.SysOrigin, + Origin: input.PropsOrigin, + SourceGroupID: *input.Reward.RewardGroupID, + }) + if err != nil { + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + } + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "") +} + +// getOrCreateRewardLog 创建或复用发奖日志,保证同 businessKey 幂等。 +func (s *InviteService) getOrCreateRewardLog(ctx context.Context, businessKey string, input rewardDispatchInput, goldAmount int64, rewardGroupID *int64) (*model.InviteCampaignRewardLog, error) { + var record model.InviteCampaignRewardLog + err := s.repo.DB.WithContext(ctx). + Where("business_key = ?", businessKey). + First(&record).Error + if err == nil { + return &record, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + id, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + now := time.Now() + record = model.InviteCampaignRewardLog{ + ID: id, + SysOrigin: input.SysOrigin, + RelationID: input.RelationID, + InviterUserID: input.InviterUserID, + InviteeUserID: input.InviteeUserID, + TargetUserID: input.TargetUserID, + RuleID: input.RuleID, + RewardScene: input.Scene, + BusinessKey: businessKey, + GoldAmount: goldAmount, + RewardGroupID: rewardGroupID, + Status: rewardLogStatusPending, + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return nil, err + } + if err := s.repo.DB.WithContext(ctx).Where("business_key = ?", businessKey).First(&record).Error; err != nil { + return nil, err + } + } + return &record, nil +} + +// updateRewardLogStatus 更新发奖日志状态和错误信息。 +func (s *InviteService) updateRewardLogStatus(ctx context.Context, id int64, status, message string) error { + return s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRewardLog{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "error_message": truncate(message, 255), + "update_time": time.Now(), + }).Error +} + +// mergeRewardedRuleIDs 把新命中的规则 ID 合并到进度表。 +func (s *InviteService) mergeRewardedRuleIDs(ctx context.Context, relationID int64, ruleIDs []int64) error { + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var progress model.InviteCampaignInviteeProgress + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("relation_id = ?", relationID). + First(&progress).Error; err != nil { + return err + } + current := parseRewardedRuleIDs(progress.RewardedRuleIDs) + for _, ruleID := range ruleIDs { + current[ruleID] = struct{}{} + } + progress.RewardedRuleIDs = formatRewardedRuleIDs(current) + progress.UpdateTime = time.Now() + return tx.Save(&progress).Error + }) +} + +// optionalRuleID 把规则指针安全转换成规则 ID 指针。 +func optionalRuleID(rule *model.InviteCampaignRewardRule) *int64 { + if rule == nil || rule.ID == 0 { + return nil + } + return refInt64(rule.ID) +} + +// refInt64 返回 int64 指针。 +func refInt64(value int64) *int64 { + return &value +} + +// refTime 返回 time.Time 指针。 +func refTime(value time.Time) *time.Time { + return &value +} + +// mapGoldOrigin 把规则类型映射成金币发奖来源。 +func mapGoldOrigin(ruleType string) string { + switch ruleType { + case ruleTypeInviterBind: + return "INVITED_NEW_USER_REWARD" + case ruleTypeInviteeRecharge: + return "INVITED_USER_FIRST_RECHARGE_REWARD" + case ruleTypeInviterTotalRecharge: + return "CUMULATIVE_RECHARGE_REWARDS" + default: + return "INVITE_USER_REWARDS" + } +} + +// mapPropsOrigin 把规则类型映射成道具发奖来源。 +func mapPropsOrigin(ruleType string) string { + switch ruleType { + case ruleTypeInviterTotalRecharge: + return "CUMULATIVE_RECHARGE_REWARDS" + default: + return "INVITE_USER_REWARDS" + } +} diff --git a/internal/service/invite/store.go b/internal/service/invite/store.go new file mode 100644 index 0000000..9238930 --- /dev/null +++ b/internal/service/invite/store.go @@ -0,0 +1,207 @@ +package invite + +import ( + "chatapp3-golang/internal/model" + "context" + "encoding/json" + "errors" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// loadBundle 加载某个系统下的邀请活动主配置和启用规则。 +func (s *InviteService) loadBundle(ctx context.Context, sysOrigin string) (*campaignBundle, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + cacheKey := "invite:campaign:config:" + sysOrigin + if raw, err := s.repo.Redis.Get(ctx, cacheKey).Result(); err == nil && raw != "" { + var bundle campaignBundle + if jsonErr := json.Unmarshal([]byte(raw), &bundle); jsonErr == nil { + return &bundle, nil + } + } + + bundle := &campaignBundle{ + Config: model.InviteCampaignConfig{ + SysOrigin: sysOrigin, + Enabled: false, + Timezone: defaultTimezone, + AntiCheatSameIPOnce: true, + AntiCheatSameDeviceOnce: true, + }, + } + var configEntity model.InviteCampaignConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configEntity).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if err == nil { + bundle.Config = configEntity + if strings.TrimSpace(bundle.Config.Timezone) == "" { + bundle.Config.Timezone = defaultTimezone + } + } + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + Order("sort asc, threshold_value asc, id asc"). + Find(&bundle.Rules).Error; err != nil { + return nil, err + } + payload, _ := json.Marshal(bundle) + s.repo.Redis.Set(ctx, cacheKey, payload, 0) + return bundle, nil +} + +// ensureInviteCode 确保用户拥有邀请码;没有则创建一个新的。 +func (s *InviteService) ensureInviteCode(ctx context.Context, user AuthUser) (string, error) { + if mapping, err := s.findInviteCodeMappingByUserID(ctx, user.UserID); err == nil { + return mapping.InviteCode, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + if user.Authorization == "" { + return "", NewAppError(401, "missing_authorization", "authorization is required to generate invite code") + } + resp, err := s.java.EnsureInviteCode(ctx, user.Authorization) + if err != nil { + return "", err + } + info, infoErr := s.findUserBaseInfo(ctx, user.UserID) + if infoErr != nil { + return "", infoErr + } + mapping := model.InviteCodeMapping{ + UserID: user.UserID, + InviteCode: resp.InviteCode, + Account: info.Account, + } + if err := s.repo.DB.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"invite_code", "account"}), + }). + Create(&mapping).Error; err != nil { + return "", err + } + return resp.InviteCode, nil +} + +// findInviteCodeMappingByUserID 按用户 ID 查询邀请码映射。 +func (s *InviteService) findInviteCodeMappingByUserID(ctx context.Context, userID int64) (*model.InviteCodeMapping, error) { + var mapping model.InviteCodeMapping + err := s.repo.DB.WithContext(ctx). + Where("user_id = ?", userID). + First(&mapping).Error + if err != nil { + return nil, err + } + return &mapping, nil +} + +// findInviteCodeMappingByCode 按邀请码查询映射记录。 +func (s *InviteService) findInviteCodeMappingByCode(ctx context.Context, inviteCode string) (*model.InviteCodeMapping, error) { + var mapping model.InviteCodeMapping + err := s.repo.DB.WithContext(ctx). + Where("invite_code = ?", inviteCode). + First(&mapping).Error + if err != nil { + return nil, err + } + return &mapping, nil +} + +// findUserBaseInfo 读取用户基础资料。 +func (s *InviteService) findUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) { + var info model.UserBaseInfo + err := s.repo.DB.WithContext(ctx). + Where("id = ?", userID). + First(&info).Error + if err != nil { + return nil, err + } + return &info, nil +} + +// findRelationByInvitee 查询某个被邀请人的已绑定关系。 +func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin string, inviteeUserID int64) (*model.InviteCampaignRelation, error) { + var relation model.InviteCampaignRelation + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND invitee_user_id = ?", sysOrigin, inviteeUserID). + First(&relation).Error + if err != nil { + return nil, err + } + return &relation, nil +} + +// findMonthlyProgress 查询邀请人的月度进度。 +func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) { + var progress model.InviteCampaignMonthlyProgress + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + First(&progress).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &model.InviteCampaignMonthlyProgress{ + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + }, nil + } + if err != nil { + return nil, err + } + return &progress, nil +} + +// loadStats 汇总邀请人的累计邀请统计。 +func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviterUserID int64) (InviteStats, error) { + stats := InviteStats{} + if err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND inviter_user_id = ? AND eligible_status = ?", sysOrigin, inviterUserID, relationStatusEligible). + Count(&stats.TotalInvitedUsers).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(COUNT(1), 0) + FROM invite_campaign_invitee_progress ip + INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id + WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? AND ip.valid_user = 1 + `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalValidUsers).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(ip.total_recharge_coins), 0) + FROM invite_campaign_invitee_progress ip + INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id + WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? + `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalRechargeCoins).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(gold_amount), 0) + FROM invite_campaign_reward_log + WHERE sys_origin = ? AND inviter_user_id = ? AND target_user_id = ? AND status = ? + `, sysOrigin, inviterUserID, inviterUserID, rewardLogStatusSuccess).Scan(&stats.RewardGoldCoins).Error; err != nil { + return stats, err + } + return stats, nil +} + +// loadClaimedRuleIDs 读取某月已领取的任务规则集合。 +func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) { + var claims []model.InviteCampaignTaskClaim + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + Find(&claims).Error; err != nil { + return nil, err + } + result := make(map[int64]bool, len(claims)) + for _, item := range claims { + result[item.RuleID] = true + } + return result, nil +} diff --git a/internal/service/invite/task.go b/internal/service/invite/task.go new file mode 100644 index 0000000..0796520 --- /dev/null +++ b/internal/service/invite/task.go @@ -0,0 +1,167 @@ +package invite + +import ( + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ClaimTask 处理邀请任务奖励领取请求。 +func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskClaimRequest) (*TaskClaimResponse, error) { + if req.RuleID == 0 { + return nil, NewAppError(400, "invalid_rule_id", "ruleId is required") + } + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + rule := findRuleByID(bundle.Rules, req.RuleID) + if rule == nil || !rule.Enabled { + return nil, NewAppError(404, "rule_not_found", "rule not found") + } + if rule.RuleType != ruleTypeInviterValidCount && rule.RuleType != ruleTypeInviterTotalRecharge { + return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") + } + + monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) + claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID) + if err != nil { + return nil, err + } + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + progressValue := monthly.TotalRecharge + if rule.RuleType == ruleTypeInviterValidCount { + progressValue = monthly.ValidUserCount + } + if claimed { + return &TaskClaimResponse{ + Claimed: true, + AlreadyClaimed: true, + RuleID: rule.ID, + RuleType: rule.RuleType, + Progress: progressValue, + Threshold: rule.ThresholdValue, + Reward: rewardFromRule(rule), + }, nil + } + if progressValue < rule.ThresholdValue { + return nil, NewAppError(400, "task_not_reached", "task threshold not reached") + } + + // 先发奖,再落领取记录;发奖侧通过 businessKey 兜底幂等。 + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + MonthKey: monthKey, + InviterUserID: refInt64(user.UserID), + TargetUserID: user.UserID, + Reward: rewardFromRule(rule), + Scene: rule.RuleType, + RuleID: refInt64(rule.ID), + BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, monthKey, rule.ID), + GoldOrigin: mapGoldOrigin(rule.RuleType), + PropsOrigin: mapPropsOrigin(rule.RuleType), + Remark: "resident invite monthly task reward", + }) + if err != nil { + return nil, err + } + + now := time.Now() + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, monthKey, rule.ID, now); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + + return &TaskClaimResponse{ + Claimed: true, + AlreadyClaimed: false, + RuleID: rule.ID, + RuleType: rule.RuleType, + Progress: progressValue, + Threshold: rule.ThresholdValue, + Reward: rewardFromRule(rule), + }, nil +} + +// hasTaskClaim 判断任务是否已经被领取。 +func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignTaskClaim{}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). + Count(&count).Error + return count > 0, err +} + +// incrementMonthlyProgressTx 在事务里更新月度进度。 +func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error { + var progress model.InviteCampaignMonthlyProgress + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + First(&progress).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + now := time.Now() + progress = model.InviteCampaignMonthlyProgress{ + ID: id, + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + CreateTime: now, + UpdateTime: now, + } + mutate(&progress) + return tx.Create(&progress).Error + } + if err != nil { + return err + } + mutate(&progress) + progress.UpdateTime = time.Now() + return tx.Save(&progress).Error +} + +// createTaskClaimIfAbsentTx 在事务里创建领取记录,避免重复领取。 +func (s *InviteService) createTaskClaimIfAbsentTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64, claimTime time.Time) error { + var existing model.InviteCampaignTaskClaim + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). + First(&existing).Error + if err == nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + return tx.Create(&model.InviteCampaignTaskClaim{ + ID: id, + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + RuleID: ruleID, + ClaimTime: claimTime, + CreateTime: claimTime, + UpdateTime: claimTime, + }).Error +} diff --git a/internal/service/invite/types.go b/internal/service/invite/types.go new file mode 100644 index 0000000..f95bcd9 --- /dev/null +++ b/internal/service/invite/types.go @@ -0,0 +1,224 @@ +package invite + +import ( + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "context" + "errors" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + defaultTimezone = "Asia/Riyadh" + defaultShareTitle = "Invite Friends, Earn Big Rewards!" + defaultShareDesc = "Invite friends to join Party App and unlock exclusive rewards." + defaultValidRechargeCoins = int64(80000) + rewardLogStatusPending = "PENDING" + rewardLogStatusSuccess = "SUCCESS" + rewardLogStatusFailed = "FAILED" + relationStatusEligible = "ELIGIBLE" + relationStatusBlocked = "BLOCKED" + blockReasonDuplicateIP = "DUPLICATE_IP" + blockReasonDuplicateDevice = "DUPLICATE_DEVICE" + ruleTypeInviterBind = "INVITER_BIND" + ruleTypeInviteeBind = "INVITEE_BIND" + ruleTypeInviteeRecharge = "INVITEE_RECHARGE" + ruleTypeInviterValidCount = "INVITER_VALID_COUNT" + ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" +) + +var errAlreadyProcessed = errors.New("invite recharge already processed") + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +// RewardView 是邀请活动奖励的通用展示结构。 +type RewardView struct { + RuleID int64 `json:"ruleId,omitempty"` + RuleType string `json:"ruleType,omitempty"` + Threshold int64 `json:"threshold,omitempty"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + ValidThreshold int64 `json:"validThreshold,omitempty"` +} + +// TaskView 是邀请任务进度及领取状态的展示结构。 +type TaskView struct { + RuleID int64 `json:"ruleId"` + RuleType string `json:"ruleType"` + Threshold int64 `json:"threshold"` + Progress int64 `json:"progress"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + Achieved bool `json:"achieved"` + Claimed bool `json:"claimed"` +} + +// InviteStats 是邀请活动累计统计数据。 +type InviteStats struct { + TotalInvitedUsers int64 `json:"totalInvitedUsers"` + TotalValidUsers int64 `json:"totalValidUsers"` + TotalRechargeCoins int64 `json:"totalRechargeCoins"` + RewardGoldCoins int64 `json:"rewardGoldCoins"` +} + +// HomeResponse 是邀请活动首页接口返回体。 +type HomeResponse struct { + CampaignEnabled bool `json:"campaignEnabled"` + SysOrigin string `json:"sysOrigin"` + InviteCode string `json:"inviteCode"` + InviteLink string `json:"inviteLink"` + ShareTitle string `json:"shareTitle"` + ShareDesc string `json:"shareDesc"` + DownloadURL string `json:"downloadUrl"` + ServiceContact string `json:"serviceContact"` + Timezone string `json:"timezone"` + MonthKey string `json:"monthKey"` + ValidUserThreshold int64 `json:"validUserThreshold"` + InviterBindReward RewardView `json:"inviterBindReward"` + InviteeBindReward RewardView `json:"inviteeBindReward"` + InviteeRecharge []RewardView `json:"inviteeRechargeRules"` + ValidCountTasks []TaskView `json:"inviterValidCountTasks"` + TotalRechargeTasks []TaskView `json:"inviterTotalRechargeTasks"` + MonthlyProgress struct { + InviteCount int64 `json:"inviteCount"` + ValidUserCount int64 `json:"validUserCount"` + TotalRecharge int64 `json:"totalRechargeCoins"` + RewardGoldCoins int64 `json:"rewardGoldCoins"` + } `json:"monthlyProgress"` + Stats InviteStats `json:"stats"` +} + +// LandingResponse 是邀请落地页接口返回体。 +type LandingResponse struct { + CampaignEnabled bool `json:"campaignEnabled"` + SysOrigin string `json:"sysOrigin"` + InviterUserID int64 `json:"inviterUserId"` + InviterNickname string `json:"inviterNickname"` + InviterAvatar string `json:"inviterAvatar"` + InviteCode string `json:"inviteCode"` + DownloadURL string `json:"downloadUrl"` + ShareTitle string `json:"shareTitle"` + ShareDesc string `json:"shareDesc"` + InviteeReward RewardView `json:"inviteeReward"` +} + +// BindCodeRequest 是绑定邀请码入参。 +type BindCodeRequest struct { + InviteCode string `json:"inviteCode"` +} + +// BindCodeResponse 是绑定邀请码后的返回结果。 +type BindCodeResponse struct { + RelationID int64 `json:"relationId"` + Eligible bool `json:"eligible"` + BlockReason string `json:"blockReason,omitempty"` + InviterUserID int64 `json:"inviterUserId"` + InviteCode string `json:"inviteCode"` + InviterReward RewardView `json:"inviterReward"` + InviteeReward RewardView `json:"inviteeReward"` +} + +// TaskClaimRequest 是任务领取入参。 +type TaskClaimRequest struct { + RuleID int64 `json:"ruleId"` +} + +// TaskClaimResponse 是任务领取结果。 +type TaskClaimResponse struct { + Claimed bool `json:"claimed"` + AlreadyClaimed bool `json:"alreadyClaimed"` + RuleID int64 `json:"ruleId"` + RuleType string `json:"ruleType"` + Progress int64 `json:"progress"` + Threshold int64 `json:"threshold"` + Reward RewardView `json:"reward"` +} + +// RechargeSuccessRequest 是充值成功回调入参。 +type RechargeSuccessRequest struct { + OrderID string `json:"orderId"` + UserID int64 `json:"userId"` + RechargeCoins int64 `json:"rechargeCoins"` + PayTime any `json:"payTime"` + SysOrigin string `json:"sysOrigin"` +} + +// RechargeSuccessResponse 是充值成功处理结果。 +type RechargeSuccessResponse struct { + Processed bool `json:"processed"` + AlreadyProcessed bool `json:"alreadyProcessed"` + HasInviteRelation bool `json:"hasInviteRelation"` + Eligible bool `json:"eligible"` + RelationID int64 `json:"relationId,omitempty"` + InviterUserID int64 `json:"inviterUserId,omitempty"` + TotalRechargeCoins int64 `json:"totalRechargeCoins,omitempty"` + ValidUser bool `json:"validUser"` + NewlyRewardedRuleIDs []int64 `json:"newlyRewardedRuleIds,omitempty"` +} + +// rewardDispatchInput 是内部发奖统一入参。 +type rewardDispatchInput struct { + SysOrigin string + MonthKey string + RelationID *int64 + InviterUserID *int64 + InviteeUserID *int64 + TargetUserID int64 + Reward RewardView + Scene string + RuleID *int64 + BusinessKey string + GoldOrigin string + PropsOrigin string + Remark string +} + +// rewardDispatchResult 是内部发奖执行结果。 +type rewardDispatchResult struct { + Success bool +} + +// campaignBundle 聚合邀请活动主配置和规则配置。 +type campaignBundle struct { + Config model.InviteCampaignConfig `json:"config"` + Rules []model.InviteCampaignRewardRule `json:"rules"` +} + +type inviteDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type inviteGateway interface { + EnsureInviteCode(ctx context.Context, authorization string) (integration.InviteCode, error) + GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) + GrantGold(ctx context.Context, req integration.GrantGoldRequest) error + GrantProps(ctx context.Context, req integration.GrantPropsRequest) error +} + +type invitePorts struct { + DB inviteDB + Redis redis.Cmdable +} + +// InviteService 负责邀请活动首页、绑码、任务领取和充值奖励链路。 +type InviteService struct { + cfg config.Config + repo invitePorts + java inviteGateway +} + +// NewInviteService 创建邀请活动服务实例。 +func NewInviteService(cfg config.Config, db inviteDB, cache redis.Cmdable, javaGateway inviteGateway) *InviteService { + return &InviteService{ + cfg: cfg, + repo: invitePorts{DB: db, Redis: cache}, + java: javaGateway, + } +} diff --git a/internal/service/luckygift/aliases.go b/internal/service/luckygift/aliases.go new file mode 100644 index 0000000..a8a3062 --- /dev/null +++ b/internal/service/luckygift/aliases.go @@ -0,0 +1,7 @@ +package luckygift + +import "chatapp3-golang/internal/common" + +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/luckygift/draw.go b/internal/service/luckygift/draw.go new file mode 100644 index 0000000..9a1220d --- /dev/null +++ b/internal/service/luckygift/draw.go @@ -0,0 +1,280 @@ +package luckygift + +import ( + "bytes" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Draw 执行幸运礼物抽奖,流程包括幂等检查、加锁、三方抽奖、明细落库和钱包返奖。 +func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (*LuckyGiftDrawResponse, error) { + req = normalizeLuckyGiftDrawRequest(req) + if err := s.validateDrawRequest(req); err != nil { + return nil, err + } + if !s.cfg.LuckyGift.Enabled { + return nil, NewAppError(http.StatusServiceUnavailable, "lucky_gift_disabled", "lucky gift service is disabled") + } + if _, ok := findLuckyGiftConfig(s.cfg.LuckyGift.Providers, req.StandardID); !ok { + return nil, NewAppError(http.StatusServiceUnavailable, "lucky_gift_config_missing", "lucky gift provider config is missing") + } + + if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { + return nil, err + } else if cached != nil { + return cached, nil + } + + // 使用业务 ID 做分布式锁,避免同一请求并发打到三方和钱包。 + lockKey := "luckygift:draw:" + req.BusinessID + lockTTL := s.lockTTL() + lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", lockTTL).Result() + if err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "lucky_gift_lock_failed", err.Error()) + } + if !lockOK { + if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { + return nil, err + } else if cached != nil { + return cached, nil + } + return nil, NewAppError(http.StatusConflict, "lucky_gift_in_progress", "lucky gift draw is in progress") + } + defer s.repo.Redis.Del(context.Background(), lockKey) + + if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { + return nil, err + } else if cached != nil { + return cached, nil + } + + // 先确保主记录存在,这样即使流程中断也能根据状态恢复。 + requestJSON := utils.MustJSONString(req, "{}") + walletEventID := luckyGiftWalletEventID(req.SysOrigin, req.BusinessID) + record, err := s.ensureRequestRecord(ctx, req, requestJSON, walletEventID) + if err != nil { + return nil, err + } + + results, providerCode, rewardTotal, rawProviderResponse, err := s.resolveProviderResults(ctx, req, record, walletEventID) + if err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, err + } + if err := s.upsertOrderRecords(ctx, req.BusinessID, results); err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, err + } + + // 三方有中奖收益时,再调用钱包做一次金币入账,并用 walletEventID 保证幂等。 + if rewardTotal > 0 { + exists, err := s.java.ExistsGoldEvent(ctx, walletEventID) + if err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_exists_failed", err.Error()) + } + if !exists { + if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: "INCOME", + UserID: req.SendUserID, + SysOrigin: req.SysOrigin, + EventID: walletEventID, + Remark: fmt.Sprintf("businessId=%s giftId=%d", req.BusinessID, req.GiftID), + Amount: integration.NewPennyAmountPayloadFromDollar(rewardTotal), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: luckyGiftWalletOrigin, + CustomizeOriginDesc: luckyGiftWalletOrigin, + }); err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_change_failed", err.Error()) + } + } + } + + // 最终余额以钱包查询结果为准,避免依赖本地推算。 + balanceMap, err := s.java.MapGoldBalance(ctx, []int64{req.SendUserID}) + if err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_balance_failed", err.Error()) + } + balanceAfter, ok := balanceMap[req.SendUserID] + if !ok { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, "wallet balance missing") + return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_balance_missing", "wallet balance missing") + } + + resp := &LuckyGiftDrawResponse{ + BusinessID: req.BusinessID, + ProviderCode: providerCode, + RewardTotal: rewardTotal, + BalanceAfter: balanceAfter, + Results: results, + } + if err := s.updateRequestRecord(ctx, req.BusinessID, map[string]any{ + "provider_code": providerCode, + "reward_total": rewardTotal, + "balance_after": balanceAfter, + "wallet_event_id": walletEventID, + "status": luckyGiftDrawStatusSuccess, + "error_message": "", + "provider_response_json": rawProviderResponse, + "response_json": utils.MustJSONString(resp, "{}"), + "update_time": time.Now(), + }); err != nil { + return nil, err + } + + return resp, nil +} + +// resolveProviderResults 优先复用已缓存的三方响应,否则真正调用三方抽奖接口。 +func (s *LuckyGiftService) resolveProviderResults( + ctx context.Context, + req LuckyGiftDrawRequest, + record *model.LuckyGiftDrawRequestRecord, + walletEventID string, +) ([]LuckyGiftDrawResult, int, int64, string, error) { + if record != nil && strings.TrimSpace(record.ProviderResponseJSON) != "" { + providerCode, results, rewardTotal, err := parseLuckyGiftProviderResponse(record.ProviderResponseJSON, req.Orders) + if err != nil { + return nil, 0, 0, record.ProviderResponseJSON, NewAppError( + http.StatusInternalServerError, + "lucky_gift_cached_response_invalid", + err.Error(), + ) + } + return results, providerCode, rewardTotal, record.ProviderResponseJSON, nil + } + + account, _ := findLuckyGiftConfig(s.cfg.LuckyGift.Providers, req.StandardID) + rawResponse, providerCode, results, rewardTotal, err := s.callLuckyGiftProvider(ctx, account, req) + if err != nil { + return nil, providerCode, 0, rawResponse, err + } + if err := s.updateRequestRecord(ctx, req.BusinessID, map[string]any{ + "provider_code": providerCode, + "reward_total": rewardTotal, + "wallet_event_id": walletEventID, + "status": luckyGiftDrawStatusProviderSuccess, + "error_message": "", + "provider_response_json": rawResponse, + "update_time": time.Now(), + }); err != nil { + return nil, 0, 0, rawResponse, err + } + return results, providerCode, rewardTotal, rawResponse, nil +} + +// callLuckyGiftProvider 组装请求、签名并调用幸运礼物三方接口。 +func (s *LuckyGiftService) callLuckyGiftProvider( + ctx context.Context, + account config.LuckyGiftProviderConfig, + req LuckyGiftDrawRequest, +) (string, int, []LuckyGiftDrawResult, int64, error) { + endpoint, err := resolveLuckyGiftProviderEndpoint(account.URL) + if err != nil { + return "", 0, nil, 0, NewAppError(http.StatusInternalServerError, "lucky_gift_provider_endpoint_invalid", err.Error()) + } + payload, err := buildLuckyGiftProviderPayload(account, req) + if err != nil { + return "", 0, nil, 0, NewAppError(http.StatusInternalServerError, "lucky_gift_request_invalid", err.Error()) + } + body, err := json.Marshal(payload) + if err != nil { + return "", 0, nil, 0, err + } + + timeoutCtx, cancel := context.WithTimeout(ctx, s.httpClient.Timeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(timeoutCtx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", 0, nil, 0, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return "", 0, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_request_failed", err.Error()) + } + defer resp.Body.Close() + + rawBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", 0, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_read_failed", readErr.Error()) + } + rawResponse := strings.TrimSpace(string(rawBody)) + if resp.StatusCode >= http.StatusBadRequest { + return rawResponse, 0, nil, 0, NewAppError( + http.StatusBadGateway, + "lucky_gift_provider_http_error", + fmt.Sprintf("provider status=%d body=%s", resp.StatusCode, rawResponse), + ) + } + + providerCode, results, rewardTotal, err := parseLuckyGiftProviderResponse(rawResponse, req.Orders) + if err != nil { + return rawResponse, providerCode, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_invalid_response", err.Error()) + } + return rawResponse, providerCode, results, rewardTotal, nil +} + +// validateDrawRequest 校验抽奖请求必要字段和子单合法性。 +func (s *LuckyGiftService) validateDrawRequest(req LuckyGiftDrawRequest) error { + switch { + case strings.TrimSpace(req.BusinessID) == "": + return NewAppError(http.StatusBadRequest, "missing_business_id", "businessId is required") + case strings.TrimSpace(req.SysOrigin) == "": + return NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required") + case req.StandardID <= 0: + return NewAppError(http.StatusBadRequest, "missing_standard_id", "standardId is required") + case req.RoomID <= 0: + return NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + case req.SendUserID <= 0: + return NewAppError(http.StatusBadRequest, "missing_send_user_id", "sendUserId is required") + case req.GiftID <= 0: + return NewAppError(http.StatusBadRequest, "missing_gift_id", "giftId is required") + case req.GiftPrice < 0: + return NewAppError(http.StatusBadRequest, "invalid_gift_price", "giftPrice must be greater than or equal to 0") + case req.GiftNum <= 0: + return NewAppError(http.StatusBadRequest, "invalid_gift_num", "giftNum must be greater than 0") + case len(req.Orders) == 0: + return NewAppError(http.StatusBadRequest, "missing_orders", "orders are required") + } + + seen := make(map[string]struct{}, len(req.Orders)) + for _, order := range req.Orders { + if order.ID == "" { + return NewAppError(http.StatusBadRequest, "missing_order_id", "order id is required") + } + if order.AcceptUserID <= 0 { + return NewAppError(http.StatusBadRequest, "missing_accept_user_id", "acceptUserId is required") + } + if _, exists := seen[order.ID]; exists { + return NewAppError(http.StatusBadRequest, "duplicate_order_id", "order id must be unique") + } + seen[order.ID] = struct{}{} + } + return nil +} + +// normalizeLuckyGiftDrawRequest 清洗入参中的字符串字段。 +func normalizeLuckyGiftDrawRequest(req LuckyGiftDrawRequest) LuckyGiftDrawRequest { + req.BusinessID = strings.TrimSpace(req.BusinessID) + req.ConsumeAssetRecordID = strings.TrimSpace(req.ConsumeAssetRecordID) + req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin)) + for index := range req.Orders { + req.Orders[index].ID = strings.TrimSpace(req.Orders[index].ID) + } + return req +} diff --git a/internal/service/luckygift/provider.go b/internal/service/luckygift/provider.go new file mode 100644 index 0000000..060ee9f --- /dev/null +++ b/internal/service/luckygift/provider.go @@ -0,0 +1,335 @@ +package luckygift + +import ( + "chatapp3-golang/internal/config" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// buildLuckyGiftProviderPayload 构造发往三方的请求体。 +func buildLuckyGiftProviderPayload(account config.LuckyGiftProviderConfig, req LuckyGiftDrawRequest) (map[string]any, error) { + orderIDs := make([]string, 0, len(req.Orders)) + for _, order := range req.Orders { + orderIDs = append(orderIDs, luckyGiftProviderOrderID(order.ID)) + } + payload := map[string]any{ + "app_id": account.AppID, + "app_channel": account.AppChannel, + "room_id": strconv.FormatInt(req.RoomID, 10), + "user_id": strconv.FormatInt(req.SendUserID, 10), + "gift_id": req.GiftID, + "gift_price": req.GiftPrice, + "gift_num": req.GiftNum, + "gift_is_free": func() int { + if req.GiftIsFree { + return 1 + } + return 0 + }(), + "order_id": orderIDs, + "timestamp": time.Now().Unix(), + } + signature, err := buildLuckyGiftSignature(payload, account.AppKey) + if err != nil { + return nil, err + } + payload["signature"] = signature + return payload, nil +} + +// resolveLuckyGiftProviderEndpoint 校验并补齐三方接口地址。 +func resolveLuckyGiftProviderEndpoint(rawURL string) (string, error) { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return "", fmt.Errorf("provider url is empty") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", err + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("provider url must include scheme and host") + } + + const luckyGiftStartGamePath = "/lucky_gift/start_game" + path := strings.TrimRight(parsed.Path, "/") + if path == "" { + parsed.Path = luckyGiftStartGamePath + return parsed.String(), nil + } + if path == luckyGiftStartGamePath { + parsed.Path = luckyGiftStartGamePath + return parsed.String(), nil + } + + parsed.Path = path + luckyGiftStartGamePath + return parsed.String(), nil +} + +// buildLuckyGiftSignature 按约定的 key 排序规则生成签名。 +func buildLuckyGiftSignature(payload map[string]any, appKey string) (string, error) { + keys := make([]string, 0, len(payload)) + for key := range payload { + if strings.EqualFold(key, "signature") { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)+1) + for _, key := range keys { + value, err := stringifyLuckyGiftSignatureValue(key, payload[key]) + if err != nil { + return "", err + } + parts = append(parts, key+"="+value) + } + parts = append(parts, "app_key="+strings.TrimSpace(appKey)) + sum := md5.Sum([]byte(strings.Join(parts, "&"))) + return hex.EncodeToString(sum[:]), nil +} + +// stringifyLuckyGiftSignatureValue 把参与签名的字段值转换成稳定字符串。 +func stringifyLuckyGiftSignatureValue(key string, value any) (string, error) { + if key == "order_id" { + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + return url.QueryEscape(string(raw)), nil + } + switch typed := value.(type) { + case string: + return typed, nil + case int: + return strconv.Itoa(typed), nil + case int64: + return strconv.FormatInt(typed, 10), nil + case float64: + return strconv.FormatInt(int64(typed), 10), nil + case json.Number: + return typed.String(), nil + default: + return strings.TrimSpace(fmt.Sprint(typed)), nil + } +} + +// parseLuckyGiftProviderResponse 解析三方响应,并与本地订单列表做对齐校验。 +func parseLuckyGiftProviderResponse(raw string, orders []LuckyGiftDrawOrderInput) (int, []LuckyGiftDrawResult, int64, error) { + payload, err := decodeLuckyGiftJSON(raw) + if err != nil { + return 0, nil, 0, fmt.Errorf("decode provider response: %w", err) + } + code := int(readLuckyGiftInt64(payload["code"])) + if code == luckyGiftProviderFuseCode { + results := zeroLuckyGiftResults(orders) + return code, results, 0, nil + } + if code != luckyGiftProviderSuccessCode { + return code, nil, 0, fmt.Errorf("provider code=%d message=%s", code, readLuckyGiftMessage(payload)) + } + + data := readLuckyGiftMap(payload["data"]) + orderList := readLuckyGiftSlice(data["order_list"]) + if len(orderList) == 0 { + orderList = readLuckyGiftSlice(data["orderList"]) + } + if len(orderList) == 0 { + orderList = readLuckyGiftSlice(data["OrderList"]) + } + if len(orderList) != len(orders) { + return code, nil, 0, fmt.Errorf("provider order_list size mismatch: want=%d got=%d", len(orders), len(orderList)) + } + + parsed := make(map[string]LuckyGiftDrawResult, len(orderList)) + for _, item := range orderList { + row := readLuckyGiftMap(item) + providerOrderID := strings.TrimSpace(readLuckyGiftString(row["order_id"])) + if providerOrderID == "" { + providerOrderID = strings.TrimSpace(readLuckyGiftString(row["orderId"])) + } + if providerOrderID == "" { + return code, nil, 0, fmt.Errorf("provider order_id is empty") + } + if _, exists := parsed[providerOrderID]; exists { + return code, nil, 0, fmt.Errorf("provider order_id duplicated: %s", providerOrderID) + } + parsed[providerOrderID] = LuckyGiftDrawResult{ + OrderID: providerOrderID, + AcceptUserID: readLuckyGiftInt64(row["accept_user_id"]), + IsWin: readLuckyGiftBool(row["is_win"], row["isWin"]), + RewardNum: readLuckyGiftInt64(firstLuckyGiftValue(row["reward_num"], row["rewardNum"])), + } + } + + results := make([]LuckyGiftDrawResult, 0, len(orders)) + var rewardTotal int64 + for _, order := range orders { + providerOrderID := luckyGiftProviderOrderID(order.ID) + result, exists := parsed[providerOrderID] + if !exists { + return code, nil, 0, fmt.Errorf("provider order missing: %s", providerOrderID) + } + if result.AcceptUserID != 0 && result.AcceptUserID != order.AcceptUserID { + return code, nil, 0, fmt.Errorf("provider accept_user_id mismatch for order %s", providerOrderID) + } + result.ID = order.ID + result.AcceptUserID = order.AcceptUserID + rewardTotal += result.RewardNum + results = append(results, result) + } + + return code, results, rewardTotal, nil +} + +// zeroLuckyGiftResults 在三方熔断或未中奖场景下生成全空中奖结果。 +func zeroLuckyGiftResults(orders []LuckyGiftDrawOrderInput) []LuckyGiftDrawResult { + results := make([]LuckyGiftDrawResult, 0, len(orders)) + for _, order := range orders { + results = append(results, LuckyGiftDrawResult{ + ID: order.ID, + OrderID: luckyGiftProviderOrderID(order.ID), + AcceptUserID: order.AcceptUserID, + IsWin: false, + RewardNum: 0, + }) + } + return results +} + +// luckyGiftProviderOrderID 生成发往三方的订单号。 +func luckyGiftProviderOrderID(orderSeed string) string { + return "LG_" + strings.TrimSpace(orderSeed) +} + +// luckyGiftWalletEventID 构造钱包幂等事件 ID。 +func luckyGiftWalletEventID(sysOrigin, businessID string) string { + return fmt.Sprintf("%s:%s:%s", luckyGiftWalletOrigin, strings.ToUpper(strings.TrimSpace(sysOrigin)), strings.TrimSpace(businessID)) +} + +// findLuckyGiftConfig 按档位 ID 选择对应的三方配置。 +func findLuckyGiftConfig(configs []config.LuckyGiftProviderConfig, standardID int64) (config.LuckyGiftProviderConfig, bool) { + for _, item := range configs { + if item.StandardID == standardID { + return item, true + } + } + for _, item := range configs { + if item.StandardID == 0 { + return item, true + } + } + return config.LuckyGiftProviderConfig{}, false +} + +// decodeLuckyGiftJSON 把三方 JSON 报文解析成 map。 +func decodeLuckyGiftJSON(raw string) (map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var payload map[string]any + if err := decoder.Decode(&payload); err != nil { + return nil, err + } + return payload, nil +} + +// readLuckyGiftMap 安全读取 map 子对象。 +func readLuckyGiftMap(value any) map[string]any { + if typed, ok := value.(map[string]any); ok { + return typed + } + return map[string]any{} +} + +// readLuckyGiftSlice 安全读取数组子对象。 +func readLuckyGiftSlice(value any) []any { + if typed, ok := value.([]any); ok { + return typed + } + return nil +} + +// readLuckyGiftString 尽量宽松地读取字符串字段。 +func readLuckyGiftString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +// readLuckyGiftInt64 尽量宽松地读取数值字段。 +func readLuckyGiftInt64(value any) int64 { + switch typed := value.(type) { + case int: + return int64(typed) + case int64: + return typed + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) + return parsed + } +} + +// readLuckyGiftBool 尽量宽松地读取布尔字段。 +func readLuckyGiftBool(values ...any) bool { + for _, value := range values { + switch typed := value.(type) { + case bool: + return typed + case int: + return typed != 0 + case int64: + return typed != 0 + case float64: + return typed != 0 + case json.Number: + parsed, _ := typed.Int64() + return parsed != 0 + case string: + text := strings.TrimSpace(strings.ToLower(typed)) + return text == "1" || text == "true" || text == "yes" + } + } + return false +} + +// readLuckyGiftMessage 从不同字段名里兜底读取错误消息。 +func readLuckyGiftMessage(payload map[string]any) string { + for _, key := range []string{"msg", "message", "errorMsg"} { + if value := strings.TrimSpace(readLuckyGiftString(payload[key])); value != "" { + return value + } + } + return "" +} + +// firstLuckyGiftValue 返回第一个非 nil 值,便于兼容三方多种字段名。 +func firstLuckyGiftValue(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} diff --git a/internal/service/luckygift/service_test.go b/internal/service/luckygift/service_test.go new file mode 100644 index 0000000..04e1a74 --- /dev/null +++ b/internal/service/luckygift/service_test.go @@ -0,0 +1,149 @@ +package luckygift + +import ( + "chatapp3-golang/internal/config" + "testing" +) + +// TestBuildLuckyGiftSignature 校验签名生成结果稳定且符合预期。 +func TestBuildLuckyGiftSignature(t *testing.T) { + payload := map[string]any{ + "app_channel": "android", + "app_id": "1001", + "gift_id": int64(111), + "gift_is_free": 0, + "gift_num": int64(17), + "gift_price": int64(150), + "order_id": []string{"LG_90001", "LG_90002"}, + "room_id": int64(12345), + "timestamp": int64(1713270000), + "user_id": int64(67890), + } + + signature, err := buildLuckyGiftSignature(payload, "app-secret") + if err != nil { + t.Fatalf("buildLuckyGiftSignature returned error: %v", err) + } + if signature != "70d22e13f3665c3d1ac784f1008e2241" { + t.Fatalf("unexpected signature: %s", signature) + } +} + +// TestParseLuckyGiftProviderResponseSuccess 校验三方正常回包解析逻辑。 +func TestParseLuckyGiftProviderResponseSuccess(t *testing.T) { + raw := `{"code":200,"message":"ok","data":{"order_list":[{"order_id":"LG_90001","is_win":0,"reward_num":0},{"order_id":"LG_90002","is_win":1,"reward_num":300}]}}` + orders := []LuckyGiftDrawOrderInput{ + {ID: "90001", AcceptUserID: 2001}, + {ID: "90002", AcceptUserID: 2002}, + } + + code, results, rewardTotal, err := parseLuckyGiftProviderResponse(raw, orders) + if err != nil { + t.Fatalf("parseLuckyGiftProviderResponse returned error: %v", err) + } + if code != 200 { + t.Fatalf("unexpected provider code: %d", code) + } + if rewardTotal != 300 { + t.Fatalf("unexpected rewardTotal: %d", rewardTotal) + } + if len(results) != 2 || results[1].RewardNum != 300 || !results[1].IsWin { + t.Fatalf("unexpected results: %+v", results) + } +} + +// TestParseLuckyGiftProviderResponseSupportsUppercaseOrderList 校验兼容大写字段名的回包。 +func TestParseLuckyGiftProviderResponseSupportsUppercaseOrderList(t *testing.T) { + raw := `{"code":200,"docs":"成功","data":{"OrderList":[{"order_id":"LG_90001","is_win":0,"reward_num":0}]}}` + orders := []LuckyGiftDrawOrderInput{ + {ID: "90001", AcceptUserID: 2001}, + } + + code, results, rewardTotal, err := parseLuckyGiftProviderResponse(raw, orders) + if err != nil { + t.Fatalf("parseLuckyGiftProviderResponse returned error: %v", err) + } + if code != 200 || rewardTotal != 0 { + t.Fatalf("unexpected result: code=%d rewardTotal=%d", code, rewardTotal) + } + if len(results) != 1 || results[0].OrderID != "LG_90001" { + t.Fatalf("unexpected results: %+v", results) + } +} + +// TestParseLuckyGiftProviderResponseFuse 校验三方熔断码会被解释为全未中奖结果。 +func TestParseLuckyGiftProviderResponseFuse(t *testing.T) { + raw := `{"code":501,"message":"fused","data":{"order_list":[]}}` + orders := []LuckyGiftDrawOrderInput{ + {ID: "90001", AcceptUserID: 2001}, + {ID: "90002", AcceptUserID: 2002}, + } + + code, results, rewardTotal, err := parseLuckyGiftProviderResponse(raw, orders) + if err != nil { + t.Fatalf("parseLuckyGiftProviderResponse returned error: %v", err) + } + if code != 501 || rewardTotal != 0 { + t.Fatalf("unexpected fuse result: code=%d rewardTotal=%d", code, rewardTotal) + } + if len(results) != 2 || results[0].RewardNum != 0 || results[1].RewardNum != 0 { + t.Fatalf("unexpected results: %+v", results) + } +} + +// TestParseLuckyGiftProviderResponseOrderCountMismatch 校验订单数量不一致时报错。 +func TestParseLuckyGiftProviderResponseOrderCountMismatch(t *testing.T) { + raw := `{"code":200,"message":"ok","data":{"order_list":[{"order_id":"LG_90001","is_win":1,"reward_num":100}]}}` + orders := []LuckyGiftDrawOrderInput{ + {ID: "90001", AcceptUserID: 2001}, + {ID: "90002", AcceptUserID: 2002}, + } + + if _, _, _, err := parseLuckyGiftProviderResponse(raw, orders); err == nil { + t.Fatalf("expected order count mismatch error") + } +} + +// TestBuildLuckyGiftProviderPayloadUsesStringRoomAndUserIDs 校验三方请求体里的房间和用户 ID 使用字符串格式。 +func TestBuildLuckyGiftProviderPayloadUsesStringRoomAndUserIDs(t *testing.T) { + payload, err := buildLuckyGiftProviderPayload(config.LuckyGiftProviderConfig{ + AppID: "9291", + AppChannel: "likei", + AppKey: "shared-key", + }, LuckyGiftDrawRequest{ + RoomID: 2043938895665598465, + SendUserID: 4569421795454615552, + GiftID: 2044387508480962561, + GiftPrice: 100, + GiftNum: 1, + Orders: []LuckyGiftDrawOrderInput{ + {ID: "1900012345678901101", AcceptUserID: 4569421795454615552}, + }, + }) + if err != nil { + t.Fatalf("buildLuckyGiftProviderPayload returned error: %v", err) + } + if _, ok := payload["room_id"].(string); !ok { + t.Fatalf("expected room_id to be string, got %T", payload["room_id"]) + } + if _, ok := payload["user_id"].(string); !ok { + t.Fatalf("expected user_id to be string, got %T", payload["user_id"]) + } +} + +// TestFindLuckyGiftConfigFallback 校验档位未命中时会回退到默认配置。 +func TestFindLuckyGiftConfigFallback(t *testing.T) { + configs := []config.LuckyGiftProviderConfig{ + {StandardID: 0, URL: "https://default.example", AppID: "1", AppChannel: "android", AppKey: "k1"}, + {StandardID: 18816, URL: "https://18816.example", AppID: "2", AppChannel: "ios", AppKey: "k2"}, + } + + cfg, ok := findLuckyGiftConfig(configs, 18816) + if !ok || cfg.URL != "https://18816.example" { + t.Fatalf("expected exact match, got ok=%v cfg=%+v", ok, cfg) + } + cfg, ok = findLuckyGiftConfig(configs, 999) + if !ok || cfg.URL != "https://default.example" { + t.Fatalf("expected fallback match, got ok=%v cfg=%+v", ok, cfg) + } +} diff --git a/internal/service/luckygift/store.go b/internal/service/luckygift/store.go new file mode 100644 index 0000000..04634cd --- /dev/null +++ b/internal/service/luckygift/store.go @@ -0,0 +1,208 @@ +package luckygift + +import ( + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "encoding/json" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ensureRequestRecord 创建或复用抽奖主记录,保证流程可恢复。 +func (s *LuckyGiftService) ensureRequestRecord( + ctx context.Context, + req LuckyGiftDrawRequest, + requestJSON string, + walletEventID string, +) (*model.LuckyGiftDrawRequestRecord, error) { + record, err := s.findRequestRecord(ctx, req.BusinessID) + if err != nil { + return nil, err + } + now := time.Now() + if record == nil { + id, err := utils.NextID() + if err != nil { + return nil, err + } + record = &model.LuckyGiftDrawRequestRecord{ + ID: id, + BusinessID: req.BusinessID, + ConsumeAssetRecordID: req.ConsumeAssetRecordID, + SysOrigin: req.SysOrigin, + StandardID: req.StandardID, + RoomID: req.RoomID, + SendUserID: req.SendUserID, + GiftID: req.GiftID, + GiftPrice: req.GiftPrice, + GiftNum: req.GiftNum, + GiftIsFree: req.GiftIsFree, + WalletEventID: walletEventID, + Status: luckyGiftDrawStatusPending, + RequestJSON: requestJSON, + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(record).Error; err != nil { + return nil, err + } + return record, nil + } + + updates := map[string]any{ + "consume_asset_record_id": req.ConsumeAssetRecordID, + "sys_origin": req.SysOrigin, + "standard_id": req.StandardID, + "room_id": req.RoomID, + "send_user_id": req.SendUserID, + "gift_id": req.GiftID, + "gift_price": req.GiftPrice, + "gift_num": req.GiftNum, + "gift_is_free": req.GiftIsFree, + "wallet_event_id": walletEventID, + "request_json": requestJSON, + "update_time": now, + } + if err := s.updateRequestRecord(ctx, req.BusinessID, updates); err != nil { + return nil, err + } + record.ConsumeAssetRecordID = req.ConsumeAssetRecordID + record.SysOrigin = req.SysOrigin + record.StandardID = req.StandardID + record.RoomID = req.RoomID + record.SendUserID = req.SendUserID + record.GiftID = req.GiftID + record.GiftPrice = req.GiftPrice + record.GiftNum = req.GiftNum + record.GiftIsFree = req.GiftIsFree + record.WalletEventID = walletEventID + record.RequestJSON = requestJSON + record.UpdateTime = now + return record, nil +} + +// findRequestRecord 按业务 ID 读取抽奖主记录。 +func (s *LuckyGiftService) findRequestRecord(ctx context.Context, businessID string) (*model.LuckyGiftDrawRequestRecord, error) { + var record model.LuckyGiftDrawRequestRecord + err := s.repo.DB.WithContext(ctx).Where("business_id = ?", businessID).First(&record).Error + if err == nil { + return &record, nil + } + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + return nil, nil +} + +// loadSuccessfulResponse 读取已经成功完成的抽奖响应,用于幂等返回。 +func (s *LuckyGiftService) loadSuccessfulResponse(ctx context.Context, businessID string) (*LuckyGiftDrawResponse, error) { + record, err := s.findRequestRecord(ctx, businessID) + if err != nil || record == nil { + return nil, err + } + if record.Status != luckyGiftDrawStatusSuccess || strings.TrimSpace(record.ResponseJSON) == "" { + return nil, nil + } + var resp LuckyGiftDrawResponse + if err := json.Unmarshal([]byte(record.ResponseJSON), &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// updateRequestRecord 局部更新主记录状态或响应信息。 +func (s *LuckyGiftService) updateRequestRecord(ctx context.Context, businessID string, updates map[string]any) error { + return s.repo.DB.WithContext(ctx). + Model(&model.LuckyGiftDrawRequestRecord{}). + Where("business_id = ?", businessID). + Updates(updates).Error +} + +// markRequestFailed 把主记录更新成失败态,并保存错误和三方回包。 +func (s *LuckyGiftService) markRequestFailed( + ctx context.Context, + businessID string, + providerCode int, + rawResponse string, + message string, +) error { + updates := map[string]any{ + "status": luckyGiftDrawStatusFailed, + "error_message": truncateLuckyGiftError(message), + "update_time": time.Now(), + } + if providerCode != 0 { + updates["provider_code"] = providerCode + } + if strings.TrimSpace(rawResponse) != "" { + updates["provider_response_json"] = rawResponse + } + return s.updateRequestRecord(ctx, businessID, updates) +} + +// truncateLuckyGiftError 截断错误信息,避免超出字段长度。 +func truncateLuckyGiftError(message string) string { + message = strings.TrimSpace(message) + if len(message) <= 1024 { + return message + } + return message[:1024] +} + +// upsertOrderRecords 批量写入抽奖子单结果,保证同一 businessId 可以重放。 +func (s *LuckyGiftService) upsertOrderRecords(ctx context.Context, businessID string, results []LuckyGiftDrawResult) error { + if len(results) == 0 { + return nil + } + now := time.Now() + rows := make([]model.LuckyGiftDrawOrderRecord, 0, len(results)) + for index, item := range results { + id, err := utils.NextID() + if err != nil { + return err + } + rows = append(rows, model.LuckyGiftDrawOrderRecord{ + ID: id, + BusinessID: businessID, + OrderSeed: item.ID, + SortNo: index + 1, + AcceptUserID: item.AcceptUserID, + ProviderOrderID: item.OrderID, + IsWin: item.IsWin, + RewardNum: item.RewardNum, + CreateTime: now, + UpdateTime: now, + }) + } + + return s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "business_id"}, + {Name: "order_seed"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "sort_no", + "accept_user_id", + "provider_order_id", + "is_win", + "reward_num", + "update_time", + }), + }).Create(&rows).Error +} + +// lockTTL 返回抽奖分布式锁过期时间。 +func (s *LuckyGiftService) lockTTL() time.Duration { + timeout := s.httpClient.Timeout + if timeout <= 0 { + timeout = 8 * time.Second + } + if timeout < 10*time.Second { + timeout = 10 * time.Second + } + return timeout + 5*time.Second +} diff --git a/internal/service/luckygift/types.go b/internal/service/luckygift/types.go new file mode 100644 index 0000000..8619c21 --- /dev/null +++ b/internal/service/luckygift/types.go @@ -0,0 +1,104 @@ +package luckygift + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "context" + "net/http" + "time" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + // 幸运礼物请求状态和三方返回码常量。 + luckyGiftDrawStatusPending = "PENDING" + luckyGiftDrawStatusProviderSuccess = "PROVIDER_SUCCESS" + luckyGiftDrawStatusSuccess = "SUCCESS" + luckyGiftDrawStatusFailed = "FAILED" + luckyGiftProviderSuccessCode = 200 + luckyGiftProviderFuseCode = 501 + luckyGiftWalletOrigin = "LUCKY_GIFT" +) + +type luckyGiftDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type luckyGiftGateway interface { + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) +} + +type luckyGiftPorts struct { + DB luckyGiftDB + Redis redis.Cmdable +} + +// LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。 +type LuckyGiftService struct { + cfg config.Config + repo luckyGiftPorts + java luckyGiftGateway + httpClient *http.Client +} + +// LuckyGiftDrawRequest 是幸运礼物抽奖入参。 +type LuckyGiftDrawRequest struct { + BusinessID string `json:"businessId"` + ConsumeAssetRecordID string `json:"consumeAssetRecordId"` + SysOrigin string `json:"sysOrigin"` + StandardID int64 `json:"standardId"` + RoomID int64 `json:"roomId"` + SendUserID int64 `json:"sendUserId"` + GiftID int64 `json:"giftId"` + GiftPrice int64 `json:"giftPrice"` + GiftNum int64 `json:"giftNum"` + GiftIsFree bool `json:"giftIsFree"` + Orders []LuckyGiftDrawOrderInput `json:"orders"` +} + +// LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。 +type LuckyGiftDrawOrderInput struct { + ID string `json:"id"` + AcceptUserID int64 `json:"acceptUserId"` +} + +// LuckyGiftDrawResponse 是幸运礼物抽奖接口返回结果。 +type LuckyGiftDrawResponse struct { + BusinessID string `json:"businessId"` + ProviderCode int `json:"providerCode"` + RewardTotal int64 `json:"rewardTotal"` + BalanceAfter int64 `json:"balanceAfter"` + Results []LuckyGiftDrawResult `json:"results"` +} + +// LuckyGiftDrawResult 描述单个收礼人的中奖结果。 +type LuckyGiftDrawResult struct { + ID string `json:"id"` + OrderID string `json:"orderId"` + AcceptUserID int64 `json:"acceptUserId"` + IsWin bool `json:"isWin"` + RewardNum int64 `json:"rewardNum"` +} + +// NewLuckyGiftService 创建幸运礼物服务,并初始化带超时的 HTTP 客户端。 +func NewLuckyGiftService(cfg config.Config, db luckyGiftDB, cache redis.Cmdable, javaGateway luckyGiftGateway) *LuckyGiftService { + timeout := cfg.LuckyGift.Timeout + if timeout <= 0 { + timeout = cfg.HTTP.Timeout + } + if timeout <= 0 { + timeout = 8 * time.Second + } + return &LuckyGiftService{ + cfg: cfg, + repo: luckyGiftPorts{DB: db, Redis: cache}, + java: javaGateway, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} diff --git a/internal/service/register_reward.go b/internal/service/register_reward.go deleted file mode 100644 index cce740f..0000000 --- a/internal/service/register_reward.go +++ /dev/null @@ -1,458 +0,0 @@ -package service - -import ( - "chatapp3-golang/internal/config" - "chatapp3-golang/internal/integration" - "chatapp3-golang/internal/model" - "chatapp3-golang/internal/repo" - "chatapp3-golang/internal/util" - "context" - "errors" - "fmt" - "log" - "strconv" - "strings" - "time" - - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -const ( - registerRewardStatusPending = "PENDING" - registerRewardStatusSuccess = "SUCCESS" - registerRewardStatusSkipped = "SKIPPED" - registerRewardStatusFailed = "FAILED" - registerRewardGoldOrigin = "REGISTER_REWARDS" - registerRewardGoldDesc = "Register reward" - registerRewardPropsOrigin = "ACTIVITY_REWARD" -) - -type registerRewardEvent struct { - EventID string - SysOrigin string - UserID int64 - CountryID int64 -} - -type registerRewardResolvedConfig struct { - GoldAmount int64 - RewardGroupID *int64 -} - -type RegisterRewardService struct { - cfg config.Config - repo *repo.Repository - java *integration.Client -} - -func NewRegisterRewardService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *RegisterRewardService { - return &RegisterRewardService{ - cfg: cfg, - repo: repository, - java: javaClient, - } -} - -func (s *RegisterRewardService) Start(ctx context.Context) error { - if strings.TrimSpace(s.cfg.RegisterRewardStreamKey) == "" { - log.Printf("register reward consumer disabled: stream key is blank") - return nil - } - if err := s.ensureConsumerGroup(ctx); err != nil { - return err - } - log.Printf( - "register reward consumer started. stream=%s group=%s consumer=%s", - s.cfg.RegisterRewardStreamKey, - s.cfg.RegisterRewardConsumerGroup, - s.cfg.RegisterRewardConsumerName, - ) - go s.consumeLoop(ctx) - return nil -} - -func (s *RegisterRewardService) consumeLoop(ctx context.Context) { - for { - if ctx.Err() != nil { - return - } - - reclaimed, err := s.reclaimPending(ctx) - if err != nil { - s.handleLoopError(ctx, "reclaim register reward messages", err) - continue - } - if reclaimed > 0 { - continue - } - - err = s.readNewMessages(ctx) - if err == nil || errors.Is(err, redis.Nil) { - continue - } - s.handleLoopError(ctx, "read register reward messages", err) - } -} - -func (s *RegisterRewardService) handleLoopError(ctx context.Context, action string, err error) { - if ctx.Err() != nil { - return - } - if isRegisterRewardNoGroupErr(err) { - if ensureErr := s.ensureConsumerGroup(ctx); ensureErr != nil { - log.Printf("%s failed and recreate consumer group failed: %v", action, ensureErr) - sleepWithContext(ctx, time.Second) - return - } - return - } - log.Printf("%s failed: %v", action, err) - sleepWithContext(ctx, time.Second) -} - -func (s *RegisterRewardService) ensureConsumerGroup(ctx context.Context) error { - err := s.repo.Redis.XGroupCreateMkStream( - ctx, - s.cfg.RegisterRewardStreamKey, - s.cfg.RegisterRewardConsumerGroup, - "0", - ).Err() - if err != nil && !strings.Contains(strings.ToUpper(err.Error()), "BUSYGROUP") { - return err - } - return nil -} - -func (s *RegisterRewardService) reclaimPending(ctx context.Context) (int, error) { - messages, _, err := s.repo.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{ - Stream: s.cfg.RegisterRewardStreamKey, - Group: s.cfg.RegisterRewardConsumerGroup, - Consumer: s.cfg.RegisterRewardConsumerName, - MinIdle: s.cfg.RegisterRewardMinIdle, - Start: "0-0", - Count: s.cfg.RegisterRewardBatchSize, - }).Result() - if err != nil { - return 0, err - } - if len(messages) == 0 { - return 0, nil - } - return len(messages), s.processMessages(ctx, messages) -} - -func (s *RegisterRewardService) readNewMessages(ctx context.Context) error { - streams, err := s.repo.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{ - Group: s.cfg.RegisterRewardConsumerGroup, - Consumer: s.cfg.RegisterRewardConsumerName, - Streams: []string{s.cfg.RegisterRewardStreamKey, ">"}, - Count: s.cfg.RegisterRewardBatchSize, - Block: s.cfg.RegisterRewardBlock, - }).Result() - if err != nil { - return err - } - for _, stream := range streams { - if err := s.processMessages(ctx, stream.Messages); err != nil { - return err - } - } - return nil -} - -func (s *RegisterRewardService) processMessages(ctx context.Context, messages []redis.XMessage) error { - for _, message := range messages { - ack, err := s.processMessage(ctx, message) - if ack { - if ackErr := s.repo.Redis.XAck( - context.Background(), - s.cfg.RegisterRewardStreamKey, - s.cfg.RegisterRewardConsumerGroup, - message.ID, - ).Err(); ackErr != nil { - log.Printf("ack register reward message failed. streamId=%s err=%v", message.ID, ackErr) - } - } - if err != nil { - return err - } - } - return nil -} - -func (s *RegisterRewardService) processMessage(ctx context.Context, message redis.XMessage) (bool, error) { - event, err := parseRegisterRewardEvent(message) - if err != nil { - log.Printf("drop malformed register reward message. streamId=%s err=%v values=%v", message.ID, err, message.Values) - return true, nil - } - - logRecord, terminal, err := s.getOrCreateLog(ctx, event, message.ID) - if err != nil { - return false, err - } - if terminal { - return true, nil - } - - resolvedConfig, ack, err := s.resolveRewardConfig(ctx, logRecord, event) - if err != nil || ack { - return ack, err - } - - if err := s.dispatchGold(ctx, event, resolvedConfig); err != nil { - _ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig) - return false, err - } - if err := s.dispatchProps(ctx, logRecord.ID, event, resolvedConfig); err != nil { - _ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig) - return false, err - } - if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusSuccess, "", resolvedConfig); err != nil { - return false, err - } - return true, nil -} - -func (s *RegisterRewardService) resolveRewardConfig(ctx context.Context, logRecord *model.ResidentRegisterRewardLog, event registerRewardEvent) (registerRewardResolvedConfig, bool, error) { - if logRecord.Status == registerRewardStatusFailed && (logRecord.GoldAmount > 0 || logRecord.RewardGroupID != nil) { - return registerRewardResolvedConfig{ - GoldAmount: logRecord.GoldAmount, - RewardGroupID: logRecord.RewardGroupID, - }, false, nil - } - - cfg, err := s.loadRegisterRewardConfig(ctx, event.SysOrigin) - if err != nil { - return registerRewardResolvedConfig{}, false, err - } - if cfg == nil || !cfg.Enabled { - err = s.updateLogStatus( - ctx, - logRecord.ID, - registerRewardStatusSkipped, - "register reward config missing or disabled", - registerRewardResolvedConfig{}, - ) - return registerRewardResolvedConfig{}, err == nil, err - } - - resolved := registerRewardResolvedConfig{ - GoldAmount: cfg.GoldAmount, - RewardGroupID: cfg.RewardGroupID, - } - if resolved.GoldAmount <= 0 && resolved.RewardGroupID == nil { - err = s.updateLogStatus( - ctx, - logRecord.ID, - registerRewardStatusSkipped, - "register reward config has no reward payload", - resolved, - ) - return registerRewardResolvedConfig{}, err == nil, err - } - if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusPending, "", resolved); err != nil { - return registerRewardResolvedConfig{}, false, err - } - return resolved, false, nil -} - -func (s *RegisterRewardService) loadRegisterRewardConfig(ctx context.Context, sysOrigin string) (*model.ResidentRegisterRewardConfig, error) { - var cfg model.ResidentRegisterRewardConfig - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", strings.ToUpper(strings.TrimSpace(sysOrigin))). - First(&cfg).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &cfg, nil -} - -func (s *RegisterRewardService) dispatchGold(ctx context.Context, event registerRewardEvent, resolved registerRewardResolvedConfig) error { - if resolved.GoldAmount <= 0 { - return nil - } - return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ - ReceiptType: "INCOME", - UserID: event.UserID, - SysOrigin: event.SysOrigin, - EventID: event.EventID + ":gold", - Remark: registerRewardGoldDesc, - Amount: integration.NewPennyAmountPayloadFromDollar(resolved.GoldAmount), - CloseDelayAsset: false, - OpUserType: "APP", - CustomizeOrigin: registerRewardGoldOrigin, - CustomizeOriginDesc: registerRewardGoldDesc, - }) -} - -func (s *RegisterRewardService) dispatchProps(ctx context.Context, logID int64, event registerRewardEvent, resolved registerRewardResolvedConfig) error { - if resolved.RewardGroupID == nil { - return nil - } - return s.java.GrantProps(ctx, integration.GrantPropsRequest{ - TrackID: logID, - AcceptUserID: event.UserID, - SysOrigin: event.SysOrigin, - Origin: registerRewardPropsOrigin, - SourceGroupID: *resolved.RewardGroupID, - }) -} - -func (s *RegisterRewardService) getOrCreateLog(ctx context.Context, event registerRewardEvent, streamMessageID string) (*model.ResidentRegisterRewardLog, bool, error) { - var record model.ResidentRegisterRewardLog - err := s.repo.DB.WithContext(ctx). - Where("event_id = ?", event.EventID). - First(&record).Error - if err == nil { - if record.StreamMessageID != streamMessageID { - if updateErr := s.repo.DB.WithContext(ctx). - Model(&model.ResidentRegisterRewardLog{}). - Where("id = ?", record.ID). - Updates(map[string]any{ - "stream_message_id": streamMessageID, - "update_time": time.Now(), - }).Error; updateErr == nil { - record.StreamMessageID = streamMessageID - } - } - return &record, isRegisterRewardTerminalStatus(record.Status), nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, false, err - } - - id, idErr := util.NextID() - if idErr != nil { - return nil, false, idErr - } - now := time.Now() - record = model.ResidentRegisterRewardLog{ - ID: id, - EventID: event.EventID, - StreamMessageID: streamMessageID, - SysOrigin: event.SysOrigin, - UserID: event.UserID, - Status: registerRewardStatusPending, - CreateTime: now, - UpdateTime: now, - } - if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { - if strings.Contains(strings.ToLower(err.Error()), "duplicate") { - return s.getOrCreateLog(ctx, event, streamMessageID) - } - return nil, false, err - } - return &record, false, nil -} - -func (s *RegisterRewardService) updateLogStatus(ctx context.Context, logID int64, status, errorMessage string, resolved registerRewardResolvedConfig) error { - return s.repo.DB.WithContext(ctx). - Model(&model.ResidentRegisterRewardLog{}). - Where("id = ?", logID). - Updates(map[string]any{ - "status": status, - "error_message": truncateRegisterRewardMessage(errorMessage), - "gold_amount": resolved.GoldAmount, - "reward_group_id": resolved.RewardGroupID, - "update_time": time.Now(), - }).Error -} - -func parseRegisterRewardEvent(message redis.XMessage) (registerRewardEvent, error) { - eventID := strings.TrimSpace(toRegisterRewardString(message.Values["eventId"])) - if eventID == "" { - return registerRewardEvent{}, fmt.Errorf("eventId is required") - } - sysOrigin := strings.ToUpper(strings.TrimSpace(toRegisterRewardString(message.Values["sysOrigin"]))) - if sysOrigin == "" { - return registerRewardEvent{}, fmt.Errorf("sysOrigin is required") - } - userID, err := toRegisterRewardInt64(message.Values["userId"]) - if err != nil { - return registerRewardEvent{}, fmt.Errorf("invalid userId: %w", err) - } - if userID <= 0 { - return registerRewardEvent{}, fmt.Errorf("invalid userId") - } - countryID, _ := toRegisterRewardInt64(message.Values["countryId"]) - return registerRewardEvent{ - EventID: eventID, - SysOrigin: sysOrigin, - UserID: userID, - CountryID: countryID, - }, nil -} - -func toRegisterRewardString(value any) string { - switch typed := value.(type) { - case nil: - return "" - case string: - return typed - case []byte: - return string(typed) - default: - return fmt.Sprint(typed) - } -} - -func toRegisterRewardInt64(value any) (int64, error) { - switch typed := value.(type) { - case nil: - return 0, fmt.Errorf("value is nil") - case int64: - return typed, nil - case int32: - return int64(typed), nil - case int: - return int64(typed), nil - case uint64: - return int64(typed), nil - case float64: - return int64(typed), nil - case string: - return strconv.ParseInt(strings.TrimSpace(typed), 10, 64) - case []byte: - return strconv.ParseInt(strings.TrimSpace(string(typed)), 10, 64) - default: - return strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) - } -} - -func isRegisterRewardTerminalStatus(status string) bool { - switch strings.ToUpper(strings.TrimSpace(status)) { - case registerRewardStatusSuccess, registerRewardStatusSkipped: - return true - default: - return false - } -} - -func isRegisterRewardNoGroupErr(err error) bool { - if err == nil { - return false - } - return strings.Contains(strings.ToUpper(err.Error()), "NOGROUP") -} - -func truncateRegisterRewardMessage(message string) string { - message = strings.TrimSpace(message) - if len(message) <= 255 { - return message - } - return message[:255] -} - -func sleepWithContext(ctx context.Context, duration time.Duration) { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - case <-timer.C: - } -} diff --git a/internal/service/registerreward/admin.go b/internal/service/registerreward/admin.go new file mode 100644 index 0000000..6856d84 --- /dev/null +++ b/internal/service/registerreward/admin.go @@ -0,0 +1,254 @@ +package registerreward + +import ( + "context" + "net/http" + "strings" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +// registerRewardDailyRecordRow 是后台分页查询时使用的中间结果,补齐了用户展示资料。 +type registerRewardDailyRecordRow struct { + model.ResidentRegisterRewardLog + 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"` // 国家名称 +} + +// PageDailyRecords 分页返回指定日期的注册奖励领取记录,供后台查看当日限额使用情况。 +func (s *RegisterRewardService) PageDailyRecords( + ctx context.Context, + sysOrigin string, + claimDate string, + cursor int, + limit int, +) (*RegisterRewardDailyRecordPageResponse, error) { + normalizedSysOrigin := strings.ToUpper(strings.TrimSpace(sysOrigin)) + if normalizedSysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + resolvedClaimDate := normalizeRegisterRewardClaimDate(claimDate) + page, size := normalizeRegisterRewardPage(cursor, limit) + + configRow, err := s.loadRegisterRewardConfig(ctx, normalizedSysOrigin) + if err != nil { + return nil, err + } + dailyLimit := int64(0) + if configRow != nil { + dailyLimit = configRow.DailyLimit + } + + var total int64 + if err := s.newDailyRecordBaseQuery(ctx, normalizedSysOrigin, resolvedClaimDate). + Count(&total).Error; err != nil { + return nil, err + } + + occupiedCount, err := s.countDailyRecordsByGrantMode( + ctx, + normalizedSysOrigin, + resolvedClaimDate, + registerRewardGrantModeIssueReward, + "", + ) + if err != nil { + return nil, err + } + issuedCount, err := s.countDailyRecordsByGrantMode( + ctx, + normalizedSysOrigin, + resolvedClaimDate, + registerRewardGrantModeIssueReward, + registerRewardStatusSuccess, + ) + if err != nil { + return nil, err + } + successCount, err := s.countDailyRecordsByStatus( + ctx, + normalizedSysOrigin, + resolvedClaimDate, + registerRewardStatusSuccess, + ) + if err != nil { + return nil, err + } + + records := make([]RegisterRewardDailyRecordView, 0) + if total > 0 { + var rows []registerRewardDailyRecordRow + if err := s.newDailyRecordBaseQuery(ctx, normalizedSysOrigin, resolvedClaimDate). + Select("log.*, user.account, user.user_avatar, user.user_nickname, user.country_code, user.country_name"). + Joins("LEFT JOIN user_base_info AS user ON user.id = log.user_id"). + Order("log.create_time desc"). + Order("log.id desc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + + records = make([]RegisterRewardDailyRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toRegisterRewardDailyRecordView(row)) + } + } + + resp := &RegisterRewardDailyRecordPageResponse{ + Date: resolvedClaimDate, + LimitEnabled: dailyLimit > 0, + DailyLimit: dailyLimit, + OccupiedCount: occupiedCount, + IssuedCount: issuedCount, + SuccessCount: successCount, + RemainingCount: resolveRegisterRewardRemainingCount( + dailyLimit, + occupiedCount, + ), + Records: records, + Total: total, + } + return resp, nil +} + +// newDailyRecordBaseQuery 创建后台领取记录分页的基础查询。 +func (s *RegisterRewardService) newDailyRecordBaseQuery( + ctx context.Context, + sysOrigin string, + claimDate string, +) *gorm.DB { + return s.repo.DB.WithContext(ctx). + Table("resident_register_reward_log AS log"). + Where("log.sys_origin = ? AND log.claim_date = ?", sysOrigin, claimDate) +} + +// countDailyRecordsByGrantMode 统计某天某系统在指定发奖模式下的记录数。 +func (s *RegisterRewardService) countDailyRecordsByGrantMode( + ctx context.Context, + sysOrigin string, + claimDate string, + grantMode string, + status string, +) (int64, error) { + query := s.newDailyRecordBaseQuery(ctx, sysOrigin, claimDate). + Where("log.grant_mode = ?", grantMode) + if strings.TrimSpace(status) != "" { + query = query.Where("log.status = ?", strings.ToUpper(strings.TrimSpace(status))) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return total, nil +} + +// countDailyRecordsByStatus 统计某天某系统在指定处理状态下的记录数。 +func (s *RegisterRewardService) countDailyRecordsByStatus( + ctx context.Context, + sysOrigin string, + claimDate string, + status string, +) (int64, error) { + var total int64 + if err := s.newDailyRecordBaseQuery(ctx, sysOrigin, claimDate). + Where("log.status = ?", strings.ToUpper(strings.TrimSpace(status))). + Count(&total).Error; err != nil { + return 0, err + } + return total, nil +} + +// toRegisterRewardDailyRecordView 把数据库行转换成后台页面直接可用的结构。 +func toRegisterRewardDailyRecordView(row registerRewardDailyRecordRow) RegisterRewardDailyRecordView { + claimResult := deriveRegisterRewardClaimResult(row.Status, row.GrantMode) + return RegisterRewardDailyRecordView{ + ID: row.ID, + SysOrigin: row.SysOrigin, + UserID: row.UserID, + Account: row.Account, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + ClaimDate: row.ClaimDate, + GrantMode: row.GrantMode, + ClaimResult: claimResult, + ClaimResultText: formatRegisterRewardClaimResultText(claimResult), + GoldAmount: row.GoldAmount, + RewardGroupID: row.RewardGroupID, + Status: strings.ToUpper(strings.TrimSpace(row.Status)), + ErrorMessage: strings.TrimSpace(row.ErrorMessage), + CreateTime: formatRegisterRewardTime(row.CreateTime), + UpdateTime: formatRegisterRewardTime(row.UpdateTime), + } +} + +// normalizeRegisterRewardPage 规范分页参数,避免非法页码或 pageSize。 +func normalizeRegisterRewardPage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +// resolveRegisterRewardRemainingCount 计算当前日期还剩余多少可发名额。 +func resolveRegisterRewardRemainingCount(dailyLimit int64, occupiedCount int64) int64 { + if dailyLimit <= 0 { + return 0 + } + if occupiedCount >= dailyLimit { + return 0 + } + return dailyLimit - occupiedCount +} + +// deriveRegisterRewardClaimResult 统一归并后台页面展示的领取结果。 +func deriveRegisterRewardClaimResult(status string, grantMode string) string { + normalizedStatus := strings.ToUpper(strings.TrimSpace(status)) + normalizedGrantMode := strings.ToUpper(strings.TrimSpace(grantMode)) + + switch { + case normalizedGrantMode == registerRewardGrantModeQuotaExhausted && normalizedStatus == registerRewardStatusSuccess: + return registerRewardClaimResultQuotaExhausted + case normalizedGrantMode == registerRewardGrantModeIssueReward && normalizedStatus == registerRewardStatusSuccess: + return registerRewardClaimResultRewarded + case normalizedGrantMode == registerRewardGrantModeIssueReward && normalizedStatus == registerRewardStatusFailed: + return registerRewardClaimResultFailed + case normalizedGrantMode == registerRewardGrantModeIssueReward && normalizedStatus == registerRewardStatusPending: + return registerRewardClaimResultPending + case normalizedStatus == registerRewardStatusSkipped: + return registerRewardClaimResultSkipped + default: + return registerRewardClaimResultPending + } +} + +// formatRegisterRewardClaimResultText 返回后台页面展示用的中文领取结果文案。 +func formatRegisterRewardClaimResultText(claimResult string) string { + switch strings.ToUpper(strings.TrimSpace(claimResult)) { + case registerRewardClaimResultRewarded: + return "已发放" + case registerRewardClaimResultQuotaExhausted: + return "名额已满,视为领取成功" + case registerRewardClaimResultFailed: + return "发奖失败,待补发" + case registerRewardClaimResultSkipped: + return "已跳过" + default: + return "发放中" + } +} diff --git a/internal/service/registerreward/consumer.go b/internal/service/registerreward/consumer.go new file mode 100644 index 0000000..9e6dcc9 --- /dev/null +++ b/internal/service/registerreward/consumer.go @@ -0,0 +1,202 @@ +package registerreward + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// Start 启动注册奖励消费任务;若未配置 Stream,则直接跳过。 +func (s *RegisterRewardService) Start(ctx context.Context) error { + if strings.TrimSpace(s.cfg.Worker.RegisterReward.StreamKey) == "" { + log.Printf("register reward consumer disabled: stream key is blank") + return nil + } + if err := s.ensureConsumerGroup(ctx); err != nil { + return err + } + log.Printf( + "register reward consumer started. stream=%s group=%s consumer=%s", + s.cfg.Worker.RegisterReward.StreamKey, + s.cfg.Worker.RegisterReward.ConsumerGroup, + s.cfg.Worker.RegisterReward.ConsumerName, + ) + go s.consumeLoop(ctx) + return nil +} + +// consumeLoop 先认领超时消息,再持续读取新消息。 +func (s *RegisterRewardService) consumeLoop(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + + reclaimed, err := s.reclaimPending(ctx) + if err != nil { + s.handleLoopError(ctx, "reclaim register reward messages", err) + continue + } + if reclaimed > 0 { + continue + } + + err = s.readNewMessages(ctx) + if err == nil || errors.Is(err, redis.Nil) { + continue + } + s.handleLoopError(ctx, "read register reward messages", err) + } +} + +// handleLoopError 统一处理消费循环中的错误,并在必要时重建消费者组。 +func (s *RegisterRewardService) handleLoopError(ctx context.Context, action string, err error) { + if ctx.Err() != nil { + return + } + if isRegisterRewardNoGroupErr(err) { + if ensureErr := s.ensureConsumerGroup(ctx); ensureErr != nil { + log.Printf("%s failed and recreate consumer group failed: %v", action, ensureErr) + sleepWithContext(ctx, time.Second) + return + } + return + } + log.Printf("%s failed: %v", action, err) + sleepWithContext(ctx, time.Second) +} + +// ensureConsumerGroup 确保 Redis Stream 消费组存在。 +func (s *RegisterRewardService) ensureConsumerGroup(ctx context.Context) error { + err := s.repo.Redis.XGroupCreateMkStream( + ctx, + s.cfg.Worker.RegisterReward.StreamKey, + s.cfg.Worker.RegisterReward.ConsumerGroup, + "0", + ).Err() + if err != nil && !strings.Contains(strings.ToUpper(err.Error()), "BUSYGROUP") { + return err + } + return nil +} + +// reclaimPending 认领长时间未确认的 pending 消息,避免消息堆积。 +func (s *RegisterRewardService) reclaimPending(ctx context.Context) (int, error) { + messages, _, err := s.repo.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{ + Stream: s.cfg.Worker.RegisterReward.StreamKey, + Group: s.cfg.Worker.RegisterReward.ConsumerGroup, + Consumer: s.cfg.Worker.RegisterReward.ConsumerName, + MinIdle: s.cfg.Worker.RegisterReward.MinIdle, + Start: "0-0", + Count: s.cfg.Worker.RegisterReward.BatchSize, + }).Result() + if err != nil { + return 0, err + } + if len(messages) == 0 { + return 0, nil + } + return len(messages), s.processMessages(ctx, messages) +} + +// readNewMessages 按消费者组协议读取新消息。 +func (s *RegisterRewardService) readNewMessages(ctx context.Context) error { + streams, err := s.repo.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: s.cfg.Worker.RegisterReward.ConsumerGroup, + Consumer: s.cfg.Worker.RegisterReward.ConsumerName, + Streams: []string{s.cfg.Worker.RegisterReward.StreamKey, ">"}, + Count: s.cfg.Worker.RegisterReward.BatchSize, + Block: s.cfg.Worker.RegisterReward.Block, + }).Result() + if err != nil { + return err + } + for _, stream := range streams { + if err := s.processMessages(ctx, stream.Messages); err != nil { + return err + } + } + return nil +} + +// processMessages 顺序处理消息,并在可确认时回 ACK。 +func (s *RegisterRewardService) processMessages(ctx context.Context, messages []redis.XMessage) error { + for _, message := range messages { + ack, err := s.processMessage(ctx, message) + if ack { + if ackErr := s.repo.Redis.XAck( + context.Background(), + s.cfg.Worker.RegisterReward.StreamKey, + s.cfg.Worker.RegisterReward.ConsumerGroup, + message.ID, + ).Err(); ackErr != nil { + log.Printf("ack register reward message failed. streamId=%s err=%v", message.ID, ackErr) + } + } + if err != nil { + return err + } + } + return nil +} + +// processMessage 处理单条注册奖励消息,流程包含解析、幂等、解析配置、发奖与状态更新。 +func (s *RegisterRewardService) processMessage(ctx context.Context, message redis.XMessage) (bool, error) { + event, err := parseRegisterRewardEvent(message) + if err != nil { + log.Printf("drop malformed register reward message. streamId=%s err=%v values=%v", message.ID, err, message.Values) + return true, nil + } + + // 先创建或读取日志,保证失败重试时仍能延续同一条处理记录。 + logRecord, terminal, err := s.getOrCreateLog(ctx, event, message.ID) + if err != nil { + return false, err + } + if terminal { + return true, nil + } + + resolvedConfig, ack, err := s.resolveRewardConfig(ctx, logRecord, event) + if err != nil || ack { + return ack, err + } + + grantPlan, err := s.resolveGrantPlan(ctx, logRecord, event, resolvedConfig) + if err != nil { + return false, err + } + if grantPlan.SkipDispatch { + if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusSuccess, "", resolvedConfig); err != nil { + return false, err + } + return true, nil + } + + // 金币和道具奖励分开发放,任何一步失败都保留失败状态供后续重试。 + if err := s.dispatchGold(ctx, event, resolvedConfig); err != nil { + _ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig) + return false, err + } + if err := s.dispatchProps(ctx, logRecord.ID, event, resolvedConfig); err != nil { + _ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig) + return false, err + } + if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusSuccess, "", resolvedConfig); err != nil { + return false, err + } + if err := s.pushRewardArrivalNotice(ctx, event, logRecord, resolvedConfig); err != nil { + log.Printf( + "push register reward notice failed. logId=%d eventId=%s userId=%d err=%v", + logRecord.ID, + event.EventID, + event.UserID, + err, + ) + } + return true, nil +} diff --git a/internal/service/registerreward/content.go b/internal/service/registerreward/content.go new file mode 100644 index 0000000..9097a4e --- /dev/null +++ b/internal/service/registerreward/content.go @@ -0,0 +1,108 @@ +package registerreward + +import ( + "context" + "net/http" + "strings" + + "chatapp3-golang/internal/integration" +) + +// GetContent 返回当前登录用户的注册奖励展示内容。 +// 这个接口不负责判断“是否应该弹窗”,只负责把当前用户可见的奖励内容组织完整。 +func (s *RegisterRewardService) GetContent(ctx context.Context, user AuthUser) (*RegisterRewardContentResponse, error) { + if user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") + } + + sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin)) + if sysOrigin == "" { + return nil, NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required") + } + + configRow, err := s.loadRegisterRewardConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + + userInfo, err := s.loadUserBaseInfo(ctx, user.UserID) + if err != nil { + return nil, err + } + + logRecord, err := s.loadRegisterRewardLog(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + + registerDateMatchesServerDay := false + registerTime := "" + if userInfo != nil && !userInfo.CreateTime.IsZero() { + registerTime = formatRegisterRewardTime(userInfo.CreateTime) + registerDateMatchesServerDay = isRegisterRewardCurrentServerDate(userInfo.CreateTime) + } + + goldAmount, rewardGroupID := resolveRegisterRewardPayload(configRow, logRecord) + rewardStatus := deriveRegisterRewardStatus(logRecord, registerDateMatchesServerDay, configRow != nil && configRow.Enabled) + hasRewardRecord := logRecord != nil + + // 老用户没有历史领取日志时,不应该看到当前新配置的奖励内容。 + if !registerDateMatchesServerDay && logRecord == nil { + goldAmount = 0 + rewardGroupID = nil + } + + rewardGroupName := "" + rewardItems := make([]RegisterRewardContentItem, 0) + if rewardGroupID != nil && *rewardGroupID > 0 { + items, groupName, err := s.loadRewardContentItems(ctx, *rewardGroupID) + if err == nil { + rewardItems = items + rewardGroupName = groupName + } + } + + return &RegisterRewardContentResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + Available: goldAmount > 0 || rewardGroupID != nil, + RewardStatus: rewardStatus, + IsNewRegister: registerDateMatchesServerDay, + HasRewardRecord: hasRewardRecord, + GoldAmount: goldAmount, + RewardGroupID: rewardGroupID, + RewardGroupName: rewardGroupName, + RewardItems: rewardItems, + RegisterTime: registerTime, + }, nil +} + +// loadRewardContentItems 按奖励组 ID 拉取奖励组详情,并整理成端上可直接渲染的列表。 +func (s *RegisterRewardService) loadRewardContentItems(ctx context.Context, rewardGroupID int64) ([]RegisterRewardContentItem, string, error) { + detail, err := s.java.GetRewardGroupDetail(ctx, rewardGroupID) + if err != nil { + return nil, "", err + } + return buildRegisterRewardContentItems(detail.RewardConfigList), strings.TrimSpace(detail.Name), nil +} + +// buildRegisterRewardContentItems 把 Java 奖励组返回值映射成注册奖励接口自己的展示结构。 +func buildRegisterRewardContentItems(items []integration.RewardGroupItem) []RegisterRewardContentItem { + if len(items) == 0 { + return make([]RegisterRewardContentItem, 0) + } + + result := make([]RegisterRewardContentItem, 0, len(items)) + for _, item := range items { + result = append(result, RegisterRewardContentItem{ + ID: int64(item.ID), + Type: strings.TrimSpace(item.Type), + Name: strings.TrimSpace(item.Name), + Content: strings.TrimSpace(item.Content), + Quantity: int64(item.Quantity), + Cover: strings.TrimSpace(item.Cover), + Remark: strings.TrimSpace(item.Remark), + }) + } + return result +} diff --git a/internal/service/registerreward/content_test.go b/internal/service/registerreward/content_test.go new file mode 100644 index 0000000..a48bba5 --- /dev/null +++ b/internal/service/registerreward/content_test.go @@ -0,0 +1,44 @@ +package registerreward + +import ( + "chatapp3-golang/internal/integration" + "testing" +) + +func TestBuildRegisterRewardContentItems(t *testing.T) { + items := buildRegisterRewardContentItems([]integration.RewardGroupItem{ + { + ID: 101, + Type: " PROPS ", + Name: " Avatar Frame ", + Content: "frame_1", + Quantity: 7, + Cover: " https://example.com/frame.png ", + Remark: " test remark ", + }, + }) + + if len(items) != 1 { + t.Fatalf("buildRegisterRewardContentItems() len = %d, want 1", len(items)) + } + + got := items[0] + if got.ID != 101 { + t.Fatalf("item.ID = %d, want 101", got.ID) + } + if got.Type != "PROPS" { + t.Fatalf("item.Type = %q, want %q", got.Type, "PROPS") + } + if got.Name != "Avatar Frame" { + t.Fatalf("item.Name = %q, want %q", got.Name, "Avatar Frame") + } + if got.Quantity != 7 { + t.Fatalf("item.Quantity = %d, want 7", got.Quantity) + } + if got.Cover != "https://example.com/frame.png" { + t.Fatalf("item.Cover = %q, want trimmed url", got.Cover) + } + if got.Remark != "test remark" { + t.Fatalf("item.Remark = %q, want trimmed remark", got.Remark) + } +} diff --git a/internal/service/registerreward/debug.go b/internal/service/registerreward/debug.go new file mode 100644 index 0000000..77a5e2e --- /dev/null +++ b/internal/service/registerreward/debug.go @@ -0,0 +1,106 @@ +package registerreward + +import "sync" + +// registerRewardNoticeHub 维护本地调试页订阅的通知监听器。 +// 这里使用进程内广播,目的是在不接入 App IM SDK 的前提下,也能验证 +// “注册成功 -> Go 发奖 -> Go 发 REGISTER_REWARD_GRANTED 通知”这条链路是否打通。 +type registerRewardNoticeHub struct { + mu sync.RWMutex + subscribers map[int64]map[chan RegisterRewardNoticeEvent]struct{} + recent map[int64][]RegisterRewardNoticeEvent +} + +// newRegisterRewardNoticeHub 创建进程内通知广播中心。 +func newRegisterRewardNoticeHub() *registerRewardNoticeHub { + return ®isterRewardNoticeHub{ + subscribers: make(map[int64]map[chan RegisterRewardNoticeEvent]struct{}), + recent: make(map[int64][]RegisterRewardNoticeEvent), + } +} + +// subscribe 按 userId 订阅注册奖励通知镜像。 +func (h *registerRewardNoticeHub) subscribe(userID int64) (<-chan RegisterRewardNoticeEvent, func()) { + stream := make(chan RegisterRewardNoticeEvent, 16) + + h.mu.Lock() + recent := append([]RegisterRewardNoticeEvent(nil), h.recent[userID]...) + if _, ok := h.subscribers[userID]; !ok { + h.subscribers[userID] = make(map[chan RegisterRewardNoticeEvent]struct{}) + } + h.subscribers[userID][stream] = struct{}{} + h.mu.Unlock() + + for _, event := range recent { + select { + case stream <- event: + default: + } + } + + cancel := func() { + h.mu.Lock() + defer h.mu.Unlock() + + group, ok := h.subscribers[userID] + if !ok { + return + } + + if _, exists := group[stream]; exists { + delete(group, stream) + close(stream) + } + + if len(group) == 0 { + delete(h.subscribers, userID) + } + } + + return stream, cancel +} + +// publish 把通知镜像广播给当前用户的所有本地订阅者。 +// 这里使用非阻塞写入,避免测试页断开或消费变慢时反向卡住正式发奖链路。 +func (h *registerRewardNoticeHub) publish(event RegisterRewardNoticeEvent) { + if event.UserID <= 0 { + return + } + + h.mu.Lock() + h.recent[event.UserID] = append(h.recent[event.UserID], event) + if len(h.recent[event.UserID]) > 10 { + h.recent[event.UserID] = append([]RegisterRewardNoticeEvent(nil), h.recent[event.UserID][len(h.recent[event.UserID])-10:]...) + } + group := h.subscribers[event.UserID] + targets := make([]chan RegisterRewardNoticeEvent, 0, len(group)) + for stream := range group { + targets = append(targets, stream) + } + h.mu.Unlock() + + for _, stream := range targets { + select { + case stream <- event: + default: + } + } +} + +// SubscribeDebugNotices 给本地测试页提供注册奖励通知订阅能力。 +func (s *RegisterRewardService) SubscribeDebugNotices(userID int64) (<-chan RegisterRewardNoticeEvent, func()) { + if s == nil || s.debug == nil { + closed := make(chan RegisterRewardNoticeEvent) + close(closed) + return closed, func() {} + } + return s.debug.subscribe(userID) +} + +// publishDebugNotice 把刚刚触发的 REGISTER_REWARD_GRANTED 镜像到本地调试流。 +func (s *RegisterRewardService) publishDebugNotice(event RegisterRewardNoticeEvent) { + if s == nil || s.debug == nil { + return + } + s.debug.publish(event) +} diff --git a/internal/service/registerreward/debug_test.go b/internal/service/registerreward/debug_test.go new file mode 100644 index 0000000..a0c0360 --- /dev/null +++ b/internal/service/registerreward/debug_test.go @@ -0,0 +1,33 @@ +package registerreward + +import ( + "testing" + "time" +) + +func TestRegisterRewardNoticeHubPublish(t *testing.T) { + hub := newRegisterRewardNoticeHub() + stream, cancel := hub.subscribe(1001) + defer cancel() + + expected := RegisterRewardNoticeEvent{ + NoticeType: "REGISTER_REWARD_GRANTED", + UserID: 1001, + EventID: "REGISTER_REWARD:LIKEI:1001", + SentAt: time.Now(), + } + + hub.publish(expected) + + select { + case got := <-stream: + if got.UserID != expected.UserID { + t.Fatalf("publish() userId = %d, want %d", got.UserID, expected.UserID) + } + if got.NoticeType != expected.NoticeType { + t.Fatalf("publish() noticeType = %s, want %s", got.NoticeType, expected.NoticeType) + } + case <-time.After(time.Second): + t.Fatal("publish() timeout waiting for event") + } +} diff --git a/internal/service/registerreward/notify.go b/internal/service/registerreward/notify.go new file mode 100644 index 0000000..984af3d --- /dev/null +++ b/internal/service/registerreward/notify.go @@ -0,0 +1,80 @@ +package registerreward + +import ( + "context" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" +) + +const ( + // registerRewardNoticeType 是注册奖励实时通知的消息类型,供 app 侧按 socket 事件识别后弹奖励页。 + registerRewardNoticeType = "REGISTER_REWARD_GRANTED" + // registerRewardNoticeTitle 是兜底展示标题;正常情况下端上会直接消费 expand 并展示奖励页。 + registerRewardNoticeTitle = "Register reward" + // registerRewardNoticeContent 是兜底展示文案。 + registerRewardNoticeContent = "Reward granted" +) + +// registerRewardNoticeExpand 是通过 IM/socket 下发给 app 的扩展载荷。 +type registerRewardNoticeExpand struct { + Scene string `json:"scene"` // 固定业务场景,便于端上统一分发 + ShowRewardPopup bool `json:"showRewardPopup"` // 端上接到后是否直接展示奖励弹层 + UserID int64 `json:"userId"` // 用户 ID + SysOrigin string `json:"sysOrigin"` // 系统标识 + EventID string `json:"eventId"` // 注册奖励事件 ID + ClaimDate string `json:"claimDate,omitempty"` // 占用奖励日期 + ClaimResult string `json:"claimResult"` // 当前领取结果,注册奖励实际发放时固定为 REWARDED + GoldAmount int64 `json:"goldAmount"` // 实际金币奖励 + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` // 实际道具奖励组 ID +} + +// pushRewardArrivalNotice 在自动发奖成功后给用户推送一条实时官方通知,app 通过 noticeType + expand 弹奖励页。 +func (s *RegisterRewardService) pushRewardArrivalNotice( + ctx context.Context, + event registerRewardEvent, + logRecord *model.ResidentRegisterRewardLog, + resolved registerRewardResolvedConfig, +) error { + if event.UserID <= 0 { + return nil + } + if resolved.GoldAmount <= 0 && resolved.RewardGroupID == nil { + return nil + } + + if err := s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{ + ToAccounts: []int64{event.UserID}, + NoticeType: registerRewardNoticeType, + Title: registerRewardNoticeTitle, + Content: registerRewardNoticeContent, + Expand: registerRewardNoticeExpand{ + Scene: "REGISTER_REWARD", + ShowRewardPopup: true, + UserID: event.UserID, + SysOrigin: event.SysOrigin, + EventID: event.EventID, + ClaimDate: logRecord.ClaimDate, + ClaimResult: registerRewardClaimResultRewarded, + GoldAmount: resolved.GoldAmount, + RewardGroupID: resolved.RewardGroupID, + }, + }); err != nil { + return err + } + + s.publishDebugNotice(RegisterRewardNoticeEvent{ + NoticeType: registerRewardNoticeType, + Scene: "REGISTER_REWARD", + UserID: event.UserID, + SysOrigin: event.SysOrigin, + EventID: event.EventID, + ClaimDate: logRecord.ClaimDate, + ClaimResult: registerRewardClaimResultRewarded, + GoldAmount: resolved.GoldAmount, + RewardGroupID: resolved.RewardGroupID, + SentAt: time.Now(), + }) + return nil +} diff --git a/internal/service/registerreward/quota.go b/internal/service/registerreward/quota.go new file mode 100644 index 0000000..4a7f04f --- /dev/null +++ b/internal/service/registerreward/quota.go @@ -0,0 +1,129 @@ +package registerreward + +import ( + "context" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// registerRewardGrantPlan 表示当前注册奖励日志最终命中的发奖决策。 +type registerRewardGrantPlan struct { + ClaimDate string // 领取日期,按每日限额口径记录 + GrantMode string // 发奖决策模式 + SkipDispatch bool // 是否跳过真实发奖动作 +} + +// resolveGrantPlan 负责确定本次注册奖励是否还能占用当天名额。 +func (s *RegisterRewardService) resolveGrantPlan( + ctx context.Context, + logRecord *model.ResidentRegisterRewardLog, + event registerRewardEvent, + resolved registerRewardResolvedConfig, +) (registerRewardGrantPlan, error) { + // 已经有过发奖决策的日志,失败重试时必须沿用原始结果,避免重试把配额重新算乱。 + if strings.TrimSpace(logRecord.ClaimDate) != "" && strings.TrimSpace(logRecord.GrantMode) != "" { + return registerRewardGrantPlan{ + ClaimDate: logRecord.ClaimDate, + GrantMode: logRecord.GrantMode, + SkipDispatch: strings.ToUpper(strings.TrimSpace(logRecord.GrantMode)) == registerRewardGrantModeQuotaExhausted, + }, nil + } + + claimDate := currentRegisterRewardClaimDate() + if resolved.DailyLimit <= 0 { + if err := updateRegisterRewardGrantDecision( + s.repo.DB.WithContext(ctx), + logRecord.ID, + claimDate, + registerRewardGrantModeIssueReward, + ); err != nil { + return registerRewardGrantPlan{}, err + } + return registerRewardGrantPlan{ + ClaimDate: claimDate, + GrantMode: registerRewardGrantModeIssueReward, + }, nil + } + + plan := registerRewardGrantPlan{ + ClaimDate: claimDate, + GrantMode: registerRewardGrantModeIssueReward, + } + + err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 通过锁定当前系统的配置行,串行化每日限额检查,避免并发用户同时穿透当天名额。 + var configRow model.ResidentRegisterRewardConfig + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ?", strings.ToUpper(strings.TrimSpace(event.SysOrigin))). + First(&configRow).Error; err != nil { + return err + } + + dailyLimit := configRow.DailyLimit + if dailyLimit <= 0 { + plan.GrantMode = registerRewardGrantModeIssueReward + return updateRegisterRewardGrantDecision(tx, logRecord.ID, claimDate, plan.GrantMode) + } + + var occupiedCount int64 + if err := tx.Model(&model.ResidentRegisterRewardLog{}). + Where("sys_origin = ? AND claim_date = ? AND grant_mode = ?", event.SysOrigin, claimDate, registerRewardGrantModeIssueReward). + Count(&occupiedCount).Error; err != nil { + return err + } + + if occupiedCount >= dailyLimit { + plan.GrantMode = registerRewardGrantModeQuotaExhausted + plan.SkipDispatch = true + } + + return updateRegisterRewardGrantDecision(tx, logRecord.ID, claimDate, plan.GrantMode) + }) + if err != nil { + return registerRewardGrantPlan{}, err + } + + return plan, nil +} + +// updateRegisterRewardGrantDecision 回写日志的领取日期和发奖决策结果。 +func updateRegisterRewardGrantDecision( + db *gorm.DB, + logID int64, + claimDate string, + grantMode string, +) error { + return db.Model(&model.ResidentRegisterRewardLog{}). + Where("id = ?", logID). + Updates(map[string]any{ + "claim_date": strings.TrimSpace(claimDate), + "grant_mode": strings.ToUpper(strings.TrimSpace(grantMode)), + "update_time": time.Now(), + }).Error +} + +// currentRegisterRewardClaimDate 返回“每日限额”口径下的当前日期。 +func currentRegisterRewardClaimDate() string { + location, err := time.LoadLocation(registerRewardDailyQuotaTimezone) + if err != nil { + return time.Now().Format("2006-01-02") + } + return time.Now().In(location).Format("2006-01-02") +} + +// normalizeRegisterRewardClaimDate 规范后台查询传入的日期;未传时默认看今天。 +func normalizeRegisterRewardClaimDate(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return currentRegisterRewardClaimDate() + } + if _, err := time.Parse("2006-01-02", trimmed); err == nil { + return trimmed + } + return currentRegisterRewardClaimDate() +} diff --git a/internal/service/registerreward/quota_test.go b/internal/service/registerreward/quota_test.go new file mode 100644 index 0000000..7c5c3c4 --- /dev/null +++ b/internal/service/registerreward/quota_test.go @@ -0,0 +1,46 @@ +package registerreward + +import "testing" + +func TestDeriveRegisterRewardClaimResult(t *testing.T) { + t.Run("rewarded success", func(t *testing.T) { + got := deriveRegisterRewardClaimResult(registerRewardStatusSuccess, registerRewardGrantModeIssueReward) + if got != registerRewardClaimResultRewarded { + t.Fatalf("deriveRegisterRewardClaimResult() = %s, want %s", got, registerRewardClaimResultRewarded) + } + }) + + t.Run("quota exhausted success", func(t *testing.T) { + got := deriveRegisterRewardClaimResult(registerRewardStatusSuccess, registerRewardGrantModeQuotaExhausted) + if got != registerRewardClaimResultQuotaExhausted { + t.Fatalf("deriveRegisterRewardClaimResult() = %s, want %s", got, registerRewardClaimResultQuotaExhausted) + } + }) + + t.Run("failed issue reward", func(t *testing.T) { + got := deriveRegisterRewardClaimResult(registerRewardStatusFailed, registerRewardGrantModeIssueReward) + if got != registerRewardClaimResultFailed { + t.Fatalf("deriveRegisterRewardClaimResult() = %s, want %s", got, registerRewardClaimResultFailed) + } + }) +} + +func TestResolveRegisterRewardRemainingCount(t *testing.T) { + t.Run("unlimited returns zero", func(t *testing.T) { + if got := resolveRegisterRewardRemainingCount(0, 12); got != 0 { + t.Fatalf("resolveRegisterRewardRemainingCount() = %d, want 0", got) + } + }) + + t.Run("remaining cannot be negative", func(t *testing.T) { + if got := resolveRegisterRewardRemainingCount(5, 9); got != 0 { + t.Fatalf("resolveRegisterRewardRemainingCount() = %d, want 0", got) + } + }) + + t.Run("remaining uses occupied count", func(t *testing.T) { + if got := resolveRegisterRewardRemainingCount(20, 7); got != 13 { + t.Fatalf("resolveRegisterRewardRemainingCount() = %d, want 13", got) + } + }) +} diff --git a/internal/service/registerreward/reward.go b/internal/service/registerreward/reward.go new file mode 100644 index 0000000..e5e9d0d --- /dev/null +++ b/internal/service/registerreward/reward.go @@ -0,0 +1,170 @@ +package registerreward + +import ( + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +// resolveRewardConfig 决定本次事件应该发什么奖励,并把解析结果回写日志。 +func (s *RegisterRewardService) resolveRewardConfig(ctx context.Context, logRecord *model.ResidentRegisterRewardLog, event registerRewardEvent) (registerRewardResolvedConfig, bool, error) { + // 失败重试时优先沿用上一次已经落库的奖励配置,避免配置变更影响补发。 + if logRecord.Status == registerRewardStatusFailed && (logRecord.GoldAmount > 0 || logRecord.RewardGroupID != nil) { + return registerRewardResolvedConfig{ + GoldAmount: logRecord.GoldAmount, + RewardGroupID: logRecord.RewardGroupID, + }, false, nil + } + + cfg, err := s.loadRegisterRewardConfig(ctx, event.SysOrigin) + if err != nil { + return registerRewardResolvedConfig{}, false, err + } + if cfg == nil || !cfg.Enabled { + err = s.updateLogStatus( + ctx, + logRecord.ID, + registerRewardStatusSkipped, + "register reward config missing or disabled", + registerRewardResolvedConfig{}, + ) + return registerRewardResolvedConfig{}, err == nil, err + } + + resolved := registerRewardResolvedConfig{ + GoldAmount: cfg.GoldAmount, + RewardGroupID: cfg.RewardGroupID, + DailyLimit: cfg.DailyLimit, + } + // 配置存在但没有任何奖励内容时,直接标记跳过。 + if resolved.GoldAmount <= 0 && resolved.RewardGroupID == nil { + err = s.updateLogStatus( + ctx, + logRecord.ID, + registerRewardStatusSkipped, + "register reward config has no reward payload", + resolved, + ) + return registerRewardResolvedConfig{}, err == nil, err + } + if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusPending, "", resolved); err != nil { + return registerRewardResolvedConfig{}, false, err + } + return resolved, false, nil +} + +// loadRegisterRewardConfig 按系统读取注册奖励配置。 +func (s *RegisterRewardService) loadRegisterRewardConfig(ctx context.Context, sysOrigin string) (*model.ResidentRegisterRewardConfig, error) { + var cfg model.ResidentRegisterRewardConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", strings.ToUpper(strings.TrimSpace(sysOrigin))). + First(&cfg).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// dispatchGold 调用钱包接口发放金币奖励。 +func (s *RegisterRewardService) dispatchGold(ctx context.Context, event registerRewardEvent, resolved registerRewardResolvedConfig) error { + if resolved.GoldAmount <= 0 { + return nil + } + return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: "INCOME", + UserID: event.UserID, + SysOrigin: event.SysOrigin, + EventID: event.EventID + ":gold", + Remark: registerRewardGoldDesc, + Amount: integration.NewPennyAmountPayloadFromDollar(resolved.GoldAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: registerRewardGoldOrigin, + CustomizeOriginDesc: registerRewardGoldDesc, + }) +} + +// dispatchProps 调用 Java 奖励接口发放道具奖励。 +func (s *RegisterRewardService) dispatchProps(ctx context.Context, logID int64, event registerRewardEvent, resolved registerRewardResolvedConfig) error { + if resolved.RewardGroupID == nil { + return nil + } + return s.java.GrantProps(ctx, integration.GrantPropsRequest{ + TrackID: logID, + AcceptUserID: event.UserID, + SysOrigin: event.SysOrigin, + Origin: registerRewardPropsOrigin, + SourceGroupID: *resolved.RewardGroupID, + }) +} + +// getOrCreateLog 基于事件 ID 做幂等,返回现有日志或新建日志。 +func (s *RegisterRewardService) getOrCreateLog(ctx context.Context, event registerRewardEvent, streamMessageID string) (*model.ResidentRegisterRewardLog, bool, error) { + var record model.ResidentRegisterRewardLog + err := s.repo.DB.WithContext(ctx). + Where("event_id = ?", event.EventID). + First(&record).Error + if err == nil { + if record.StreamMessageID != streamMessageID { + if updateErr := s.repo.DB.WithContext(ctx). + Model(&model.ResidentRegisterRewardLog{}). + Where("id = ?", record.ID). + Updates(map[string]any{ + "stream_message_id": streamMessageID, + "update_time": time.Now(), + }).Error; updateErr == nil { + record.StreamMessageID = streamMessageID + } + } + return &record, isRegisterRewardTerminalStatus(record.Status), nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, err + } + + id, idErr := utils.NextID() + if idErr != nil { + return nil, false, idErr + } + now := time.Now() + record = model.ResidentRegisterRewardLog{ + ID: id, + EventID: event.EventID, + StreamMessageID: streamMessageID, + SysOrigin: event.SysOrigin, + UserID: event.UserID, + Status: registerRewardStatusPending, + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return s.getOrCreateLog(ctx, event, streamMessageID) + } + return nil, false, err + } + return &record, false, nil +} + +// updateLogStatus 回写处理状态、错误信息以及最终奖励内容。 +func (s *RegisterRewardService) updateLogStatus(ctx context.Context, logID int64, status, errorMessage string, resolved registerRewardResolvedConfig) error { + return s.repo.DB.WithContext(ctx). + Model(&model.ResidentRegisterRewardLog{}). + Where("id = ?", logID). + Updates(map[string]any{ + "status": status, + "error_message": truncateRegisterRewardMessage(errorMessage), + "gold_amount": resolved.GoldAmount, + "reward_group_id": resolved.RewardGroupID, + "update_time": time.Now(), + }).Error +} diff --git a/internal/service/registerreward/status.go b/internal/service/registerreward/status.go new file mode 100644 index 0000000..a54e871 --- /dev/null +++ b/internal/service/registerreward/status.go @@ -0,0 +1,188 @@ +package registerreward + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +// GetStatus 返回当前登录用户的注册奖励状态,主要用于后台联调和兜底排查。 +func (s *RegisterRewardService) GetStatus(ctx context.Context, user AuthUser) (*RegisterRewardStatusResponse, error) { + if user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") + } + + sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin)) + if sysOrigin == "" { + return nil, NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required") + } + + configRow, err := s.loadRegisterRewardConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + + userInfo, err := s.loadUserBaseInfo(ctx, user.UserID) + if err != nil { + return nil, err + } + + logRecord, err := s.loadRegisterRewardLog(ctx, sysOrigin, user.UserID) + if err != nil { + return nil, err + } + + registerDateMatchesServerDay := false + registerTime := "" + if userInfo != nil && !userInfo.CreateTime.IsZero() { + registerTime = formatRegisterRewardTime(userInfo.CreateTime) + registerDateMatchesServerDay = isRegisterRewardCurrentServerDate(userInfo.CreateTime) + } + + goldAmount, rewardGroupID := resolveRegisterRewardPayload(configRow, logRecord) + rewardStatus := deriveRegisterRewardStatus(logRecord, registerDateMatchesServerDay, configRow != nil && configRow.Enabled) + hasRewardRecord := logRecord != nil + errorMessage := "" + if logRecord != nil { + errorMessage = strings.TrimSpace(logRecord.ErrorMessage) + } + if !registerDateMatchesServerDay { + errorMessage = "" + if logRecord == nil { + goldAmount = 0 + rewardGroupID = nil + } + } + + return &RegisterRewardStatusResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + Configured: configRow != nil, + CampaignEnabled: configRow != nil && configRow.Enabled, + IsNewRegister: registerDateMatchesServerDay, + HasRewardRecord: hasRewardRecord, + RewardStatus: rewardStatus, + HasClaimed: rewardStatus == registerRewardStatusSuccess, + GoldAmount: goldAmount, + RewardGroupID: rewardGroupID, + RegisterTime: registerTime, + ErrorMessage: errorMessage, + }, nil +} + +// loadRegisterRewardLog 按用户读取注册奖励处理日志,优先按标准 eventId 命中,兼容历史数据则回退最近一条日志。 +func (s *RegisterRewardService) loadRegisterRewardLog(ctx context.Context, sysOrigin string, userID int64) (*model.ResidentRegisterRewardLog, error) { + var record model.ResidentRegisterRewardLog + eventID := buildRegisterRewardEventID(sysOrigin, userID) + err := s.repo.DB.WithContext(ctx). + Where("event_id = ?", eventID). + First(&record).Error + if err == nil { + return &record, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + err = s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). + Order("create_time desc"). + Order("id desc"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil +} + +// loadUserBaseInfo 读取用户基础资料,用创建时间兜底判断“刚注册但异步日志还没落库”的场景。 +func (s *RegisterRewardService) loadUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) { + var info model.UserBaseInfo + err := s.repo.DB.WithContext(ctx). + Where("id = ?", userID). + First(&info).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &info, nil +} + +// buildRegisterRewardEventID 按 Java 生产者相同规则拼接注册奖励事件 ID。 +func buildRegisterRewardEventID(sysOrigin string, userID int64) string { + return "REGISTER_REWARD:" + strings.ToUpper(strings.TrimSpace(sysOrigin)) + ":" + strconv.FormatInt(userID, 10) +} + +// deriveRegisterRewardStatus 统一返回联调接口里的注册奖励状态。 +func deriveRegisterRewardStatus( + logRecord *model.ResidentRegisterRewardLog, + registerDateMatchesServerDay bool, + campaignEnabled bool, +) string { + // 业务要求:只要注册日期与服务器当天日期不一致,就视为该注册奖励已经处理完成,不再继续引导端上查询。 + if !registerDateMatchesServerDay { + return registerRewardStatusSuccess + } + if logRecord != nil { + switch status := strings.ToUpper(strings.TrimSpace(logRecord.Status)); status { + case registerRewardStatusPending, + registerRewardStatusSuccess, + registerRewardStatusSkipped, + registerRewardStatusFailed: + return status + default: + return registerRewardStatusPending + } + } + if campaignEnabled { + return registerRewardStatusProcessing + } + return registerRewardStatusNotTriggered +} + +// isRegisterRewardCurrentServerDate 判断注册时间是否仍落在“服务器今天”的自然日内。 +func isRegisterRewardCurrentServerDate(value time.Time) bool { + if value.IsZero() { + return false + } + location, err := time.LoadLocation(registerRewardDailyQuotaTimezone) + if err != nil { + now := time.Now() + return value.Format("2006-01-02") == now.Format("2006-01-02") + } + return value.In(location).Format("2006-01-02") == time.Now().In(location).Format("2006-01-02") +} + +// resolveRegisterRewardPayload 优先返回日志里已命中的奖励内容,没有日志时回退当前后台配置。 +func resolveRegisterRewardPayload( + configRow *model.ResidentRegisterRewardConfig, + logRecord *model.ResidentRegisterRewardLog, +) (int64, *int64) { + if logRecord != nil { + return logRecord.GoldAmount, logRecord.RewardGroupID + } + if configRow == nil { + return 0, nil + } + return configRow.GoldAmount, configRow.RewardGroupID +} + +// formatRegisterRewardTime 统一格式化注册时间展示值。 +func formatRegisterRewardTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format("2006-01-02 15:04:05") +} diff --git a/internal/service/registerreward/status_test.go b/internal/service/registerreward/status_test.go new file mode 100644 index 0000000..9e8e802 --- /dev/null +++ b/internal/service/registerreward/status_test.go @@ -0,0 +1,83 @@ +package registerreward + +import ( + "chatapp3-golang/internal/model" + "testing" + "time" +) + +func TestDeriveRegisterRewardStatus(t *testing.T) { + t.Run("old register date defaults to success", func(t *testing.T) { + record := &model.ResidentRegisterRewardLog{Status: registerRewardStatusSuccess} + if got := deriveRegisterRewardStatus(record, false, true); got != registerRewardStatusSuccess { + t.Fatalf("deriveRegisterRewardStatus() = %s, want %s", got, registerRewardStatusSuccess) + } + }) + + t.Run("same day register uses log status when present", func(t *testing.T) { + record := &model.ResidentRegisterRewardLog{Status: registerRewardStatusFailed} + if got := deriveRegisterRewardStatus(record, true, true); got != registerRewardStatusFailed { + t.Fatalf("deriveRegisterRewardStatus() = %s, want %s", got, registerRewardStatusFailed) + } + }) + + t.Run("same day register falls back to processing when activity enabled", func(t *testing.T) { + if got := deriveRegisterRewardStatus(nil, true, true); got != registerRewardStatusProcessing { + t.Fatalf("deriveRegisterRewardStatus() = %s, want %s", got, registerRewardStatusProcessing) + } + }) + + t.Run("same day register falls back to not triggered when activity disabled", func(t *testing.T) { + if got := deriveRegisterRewardStatus(nil, true, false); got != registerRewardStatusNotTriggered { + t.Fatalf("deriveRegisterRewardStatus() = %s, want %s", got, registerRewardStatusNotTriggered) + } + }) +} + +func TestIsRegisterRewardCurrentServerDate(t *testing.T) { + location, err := time.LoadLocation(registerRewardDailyQuotaTimezone) + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + + now := time.Now().In(location) + if !isRegisterRewardCurrentServerDate(now) { + t.Fatalf("isRegisterRewardCurrentServerDate(now) = false, want true") + } + + yesterday := now.AddDate(0, 0, -1) + if isRegisterRewardCurrentServerDate(yesterday) { + t.Fatalf("isRegisterRewardCurrentServerDate(yesterday) = true, want false") + } +} + +func TestResolveRegisterRewardPayload(t *testing.T) { + configRewardGroupID := int64(2001) + logRewardGroupID := int64(3001) + + t.Run("prefer log payload", func(t *testing.T) { + goldAmount, rewardGroupID := resolveRegisterRewardPayload( + &model.ResidentRegisterRewardConfig{GoldAmount: 100, RewardGroupID: &configRewardGroupID}, + &model.ResidentRegisterRewardLog{GoldAmount: 200, RewardGroupID: &logRewardGroupID}, + ) + if goldAmount != 200 { + t.Fatalf("goldAmount = %d, want 200", goldAmount) + } + if rewardGroupID == nil || *rewardGroupID != logRewardGroupID { + t.Fatalf("rewardGroupID = %v, want %d", rewardGroupID, logRewardGroupID) + } + }) + + t.Run("fallback to config payload", func(t *testing.T) { + goldAmount, rewardGroupID := resolveRegisterRewardPayload( + &model.ResidentRegisterRewardConfig{GoldAmount: 100, RewardGroupID: &configRewardGroupID}, + nil, + ) + if goldAmount != 100 { + t.Fatalf("goldAmount = %d, want 100", goldAmount) + } + if rewardGroupID == nil || *rewardGroupID != configRewardGroupID { + t.Fatalf("rewardGroupID = %v, want %d", rewardGroupID, configRewardGroupID) + } + }) +} diff --git a/internal/service/registerreward/stream.go b/internal/service/registerreward/stream.go new file mode 100644 index 0000000..2e3cab8 --- /dev/null +++ b/internal/service/registerreward/stream.go @@ -0,0 +1,112 @@ +package registerreward + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// parseRegisterRewardEvent 把 Redis Stream 消息解析成统一事件对象。 +func parseRegisterRewardEvent(message redis.XMessage) (registerRewardEvent, error) { + eventID := strings.TrimSpace(toRegisterRewardString(message.Values["eventId"])) + if eventID == "" { + return registerRewardEvent{}, fmt.Errorf("eventId is required") + } + sysOrigin := strings.ToUpper(strings.TrimSpace(toRegisterRewardString(message.Values["sysOrigin"]))) + if sysOrigin == "" { + return registerRewardEvent{}, fmt.Errorf("sysOrigin is required") + } + userID, err := toRegisterRewardInt64(message.Values["userId"]) + if err != nil { + return registerRewardEvent{}, fmt.Errorf("invalid userId: %w", err) + } + if userID <= 0 { + return registerRewardEvent{}, fmt.Errorf("invalid userId") + } + countryID, _ := toRegisterRewardInt64(message.Values["countryId"]) + return registerRewardEvent{ + EventID: eventID, + SysOrigin: sysOrigin, + UserID: userID, + CountryID: countryID, + }, nil +} + +// toRegisterRewardString 把 Stream 字段安全转换成字符串。 +func toRegisterRewardString(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case []byte: + return string(typed) + default: + return fmt.Sprint(typed) + } +} + +// toRegisterRewardInt64 把 Stream 字段安全转换成 int64。 +func toRegisterRewardInt64(value any) (int64, error) { + switch typed := value.(type) { + case nil: + return 0, fmt.Errorf("value is nil") + case int64: + return typed, nil + case int32: + return int64(typed), nil + case int: + return int64(typed), nil + case uint64: + return int64(typed), nil + case float64: + return int64(typed), nil + case string: + return strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + case []byte: + return strconv.ParseInt(strings.TrimSpace(string(typed)), 10, 64) + default: + return strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) + } +} + +// isRegisterRewardTerminalStatus 判断该日志状态是否已经终态。 +func isRegisterRewardTerminalStatus(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case registerRewardStatusSuccess, registerRewardStatusSkipped: + return true + default: + return false + } +} + +// isRegisterRewardNoGroupErr 判断是否是消费者组不存在错误。 +func isRegisterRewardNoGroupErr(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToUpper(err.Error()), "NOGROUP") +} + +// truncateRegisterRewardMessage 截断错误信息,避免超出数据库字段长度。 +func truncateRegisterRewardMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) <= 255 { + return message + } + return message[:255] +} + +// sleepWithContext 支持被上下文中断的休眠。 +func sleepWithContext(ctx context.Context, duration time.Duration) { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + case <-timer.C: + } +} diff --git a/internal/service/registerreward/types.go b/internal/service/registerreward/types.go new file mode 100644 index 0000000..c9efa6f --- /dev/null +++ b/internal/service/registerreward/types.go @@ -0,0 +1,184 @@ +package registerreward + +import ( + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "context" + "time" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + // 注册奖励日志状态常量。 + registerRewardStatusPending = "PENDING" + registerRewardStatusSuccess = "SUCCESS" + registerRewardStatusSkipped = "SKIPPED" + registerRewardStatusFailed = "FAILED" + registerRewardStatusProcessing = "PROCESSING" + registerRewardStatusNotTriggered = "NOT_TRIGGERED" + // 注册奖励发奖决策模式常量。 + registerRewardGrantModeIssueReward = "ISSUE_REWARD" + registerRewardGrantModeQuotaExhausted = "QUOTA_EXHAUSTED" + // 注册奖励后台领取结果常量。 + registerRewardClaimResultRewarded = "REWARDED" + registerRewardClaimResultQuotaExhausted = "QUOTA_EXHAUSTED" + registerRewardClaimResultFailed = "FAILED" + registerRewardClaimResultPending = "PENDING" + registerRewardClaimResultSkipped = "SKIPPED" + // 每日限额按北京时间自然日切分。 + registerRewardDailyQuotaTimezone = "Asia/Shanghai" + // 下游发奖来源标识。 + registerRewardGoldOrigin = "REGISTER_REWARDS" + registerRewardGoldDesc = "Register reward" + registerRewardPropsOrigin = "ACTIVITY_REWARD" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +// registerRewardEvent 是从 Redis Stream 里解析出的注册事件。 +type registerRewardEvent struct { + EventID string + SysOrigin string + UserID int64 + CountryID int64 +} + +// registerRewardResolvedConfig 表示当前事件最终命中的奖励内容。 +type registerRewardResolvedConfig struct { + GoldAmount int64 + RewardGroupID *int64 + DailyLimit int64 +} + +// RegisterRewardStatusResponse 是注册奖励状态查询接口的返回体。 +type RegisterRewardStatusResponse struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + Configured bool `json:"configured"` + CampaignEnabled bool `json:"campaignEnabled"` + IsNewRegister bool `json:"isNewRegister"` + HasRewardRecord bool `json:"hasRewardRecord"` + RewardStatus string `json:"rewardStatus"` + HasClaimed bool `json:"hasClaimed"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RegisterTime string `json:"registerTime,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +// RegisterRewardContentResponse 是注册奖励内容查询接口的返回体。 +// 端上在收到 REGISTER_REWARD_GRANTED 后,可以再调这个接口拿完整的奖励展示数据。 +type RegisterRewardContentResponse struct { + UserID int64 `json:"userId"` // 当前登录用户 ID + SysOrigin string `json:"sysOrigin"` // 当前系统标识 + Available bool `json:"available"` // 当前用户是否存在可展示的注册奖励内容 + RewardStatus string `json:"rewardStatus"` // 当前奖励状态,和状态接口保持同口径 + IsNewRegister bool `json:"isNewRegister"` // 是否仍处于“服务器今天”的新注册窗口 + HasRewardRecord bool `json:"hasRewardRecord"` // 是否已经存在奖励处理日志 + GoldAmount int64 `json:"goldAmount"` // 要展示的金币奖励数 + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` // 奖励组 ID + RewardGroupName string `json:"rewardGroupName,omitempty"` // 奖励组名称 + RewardItems []RegisterRewardContentItem `json:"rewardItems"` // 奖励组内的明细项目 + RegisterTime string `json:"registerTime,omitempty"` // 用户注册时间 +} + +// RegisterRewardContentItem 是注册奖励里单个道具/资源的展示项。 +type RegisterRewardContentItem struct { + ID int64 `json:"id"` // 奖励项 ID + Type string `json:"type"` // 奖励类型 + Name string `json:"name"` // 奖励名称 + Content string `json:"content"` // 奖励内容标识 + Quantity int64 `json:"quantity"` // 发放数量 + Cover string `json:"cover"` // 奖励封面 + Remark string `json:"remark,omitempty"` // 奖励备注 +} + +// RegisterRewardDailyRecordPageResponse 是后台“注册奖励每日领取记录”分页接口返回体。 +type RegisterRewardDailyRecordPageResponse struct { + Date string `json:"date"` // 当前统计日期 + LimitEnabled bool `json:"limitEnabled"` // 是否启用了每日限额 + DailyLimit int64 `json:"dailyLimit"` // 每日限制份数 + OccupiedCount int64 `json:"occupiedCount"` // 已占用名额数,失败待补发也会占用 + IssuedCount int64 `json:"issuedCount"` // 实际已发放成功份数 + SuccessCount int64 `json:"successCount"` // 视为领取成功的人数,包含名额已满的成功 + RemainingCount int64 `json:"remainingCount"` // 剩余可用名额 + Records []RegisterRewardDailyRecordView `json:"records"` // 当日领取记录 + Total int64 `json:"total"` // 当日领取记录总数 +} + +// RegisterRewardDailyRecordView 是后台每日领取明细表格中的单条记录。 +type RegisterRewardDailyRecordView struct { + ID int64 `json:"id"` // 日志主键 + SysOrigin string `json:"sysOrigin"` // 系统标识 + UserID int64 `json:"userId"` // 用户 ID + Account string `json:"account,omitempty"` // 用户账号 + UserAvatar string `json:"userAvatar,omitempty"` // 用户头像 + UserNickname string `json:"userNickname,omitempty"` // 用户昵称 + CountryCode string `json:"countryCode,omitempty"` // 国家编码 + CountryName string `json:"countryName,omitempty"` // 国家名称 + ClaimDate string `json:"claimDate"` // 领取日期 + GrantMode string `json:"grantMode"` // 发奖决策模式 + ClaimResult string `json:"claimResult"` // 领取结果码 + ClaimResultText string `json:"claimResultText"` // 领取结果文案 + GoldAmount int64 `json:"goldAmount"` // 记录中的金币奖励 + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` // 记录中的奖励组 + Status string `json:"status"` // 当前处理状态 + ErrorMessage string `json:"errorMessage,omitempty"` // 失败信息 + CreateTime string `json:"createTime,omitempty"` // 记录创建时间 + UpdateTime string `json:"updateTime,omitempty"` // 记录更新时间 +} + +// RegisterRewardNoticeEvent 是本地调试流里输出的“注册奖励已发放”通知镜像。 +// 它不参与正式业务协议,只用于本地测试页观察 Go 是否真的触发了 REGISTER_REWARD_GRANTED。 +type RegisterRewardNoticeEvent struct { + NoticeType string `json:"noticeType"` // 固定为 REGISTER_REWARD_GRANTED + Scene string `json:"scene"` // 业务场景,固定为 REGISTER_REWARD + UserID int64 `json:"userId"` // 接收通知的用户 ID + SysOrigin string `json:"sysOrigin"` // 系统标识 + EventID string `json:"eventId"` // 注册奖励事件 ID + ClaimDate string `json:"claimDate,omitempty"` // 领取日期 + ClaimResult string `json:"claimResult"` // 当前领取结果 + GoldAmount int64 `json:"goldAmount"` // 发放金币数 + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` // 发放的奖励组 ID + SentAt time.Time `json:"sentAt"` // Go 侧确认触发通知的时间 +} + +type registerRewardDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type registerRewardGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + GrantProps(ctx context.Context, req integration.GrantPropsRequest) error + GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) + SendOfficialNoticeCustomize(ctx context.Context, req integration.OfficialNoticeCustomizeRequest) error +} + +type registerRewardPorts struct { + DB registerRewardDB + Redis redis.Cmdable +} + +// RegisterRewardService 负责消费注册事件并发放注册奖励。 +type RegisterRewardService struct { + cfg config.Config + repo registerRewardPorts + java registerRewardGateway + debug *registerRewardNoticeHub +} + +// NewRegisterRewardService 创建注册奖励服务实例。 +func NewRegisterRewardService(cfg config.Config, db registerRewardDB, cache redis.Cmdable, javaGateway registerRewardGateway) *RegisterRewardService { + return &RegisterRewardService{ + cfg: cfg, + repo: registerRewardPorts{DB: db, Redis: cache}, + java: javaGateway, + debug: newRegisterRewardNoticeHub(), + } +} diff --git a/internal/service/weekstar/aliases.go b/internal/service/weekstar/aliases.go new file mode 100644 index 0000000..2241d3a --- /dev/null +++ b/internal/service/weekstar/aliases.go @@ -0,0 +1,7 @@ +package weekstar + +import "chatapp3-golang/internal/common" + +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/weekstar/config.go b/internal/service/weekstar/config.go new file mode 100644 index 0000000..81b6182 --- /dev/null +++ b/internal/service/weekstar/config.go @@ -0,0 +1,533 @@ +package weekstar + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回指定配置详情;未传 ID 时默认返回当前生效配置,若当前周不存在则返回最近未来周配置。 +func (s *WeekStarService) GetConfig(ctx context.Context, sysOrigin string, id int64) (*WeekStarConfigResponse, error) { + var ( + configRow *model.WeekStarActivityConfig + giftRows []model.WeekStarActivityGiftConfig + rewardRows []model.WeekStarActivityRewardConfig + err error + ) + // 显式传了配置 ID 时按 ID 查;否则优先取当前生效配置,再回退未来周配置。 + if id > 0 { + configRow, giftRows, rewardRows, err = s.loadConfigBundleByID(ctx, id, sysOrigin) + } else { + configRow, giftRows, rewardRows, err = s.loadPreferredAdminConfigBundle(ctx, sysOrigin, time.Now().In(s.location)) + } + if err != nil { + return nil, err + } + if configRow == nil { + return &WeekStarConfigResponse{ + Configured: false, + SysOrigin: s.normalizeSysOrigin(sysOrigin), + Timezone: s.location.String(), + GiftConfigs: []WeekStarGiftConfigPayload{}, + RewardConfigs: []WeekStarRewardConfigPayload{}, + }, nil + } + return s.buildConfigResponse(ctx, configRow, giftRows, rewardRows, true) +} + +// PageConfigs 按系统分页返回多周活动配置列表,供后台查看和选择未来周配置。 +func (s *WeekStarService) PageConfigs(ctx context.Context, sysOrigin string, cursor, limit int) (map[string]any, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + page, size := normalizePage(cursor, limit) + + query := s.repo.DB.WithContext(ctx). + Model(&model.WeekStarActivityConfig{}). + Where("sys_origin = ?", sysOrigin) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.WeekStarActivityConfig + if total > 0 { + if err := query. + Order("start_at desc"). + Order("id desc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + } + + records := make([]WeekStarConfigResponse, 0, len(rows)) + for index := range rows { + gifts, rewards, err := s.loadConfigChildren(ctx, rows[index].ID) + if err != nil { + return nil, err + } + record, err := s.buildConfigResponse(ctx, &rows[index], gifts, rewards, false) + if err != nil { + return nil, err + } + if record != nil { + records = append(records, *record) + } + } + + return map[string]any{ + "records": records, + "total": total, + }, nil +} + +// SaveConfig 创建或更新未来周配置,并校验礼物、奖励和启用配置时间窗不重叠。 +func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfigRequest) (*WeekStarConfigResponse, error) { + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + timezone := strings.TrimSpace(req.Timezone) + if timezone == "" { + timezone = s.cfg.WeekStar.Timezone + } + location, err := time.LoadLocation(timezone) + if err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + if len(req.GiftConfigs) != 3 { + return nil, NewAppError(http.StatusBadRequest, "invalid_gift_configs", "giftConfigs must contain exactly 3 gifts") + } + if len(req.RewardConfigs) != 3 { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_configs", "rewardConfigs must contain exactly 3 rewards") + } + + startAt, err := parseDateTimeInLocation(req.StartAt, location) + if err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", err.Error()) + } + endAt, err := parseDateTimeInLocation(req.EndAt, location) + if err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_end_at", err.Error()) + } + if !endAt.After(startAt) { + return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "endAt must be later than startAt") + } + now := time.Now().In(location) + if !startAt.After(now) { + return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "only future-week configs can be created or updated") + } + + giftIDs := make(map[int64]struct{}, len(req.GiftConfigs)) + // 先把礼物和奖励配置标准化,避免后续事务里还掺杂输入清洗逻辑。 + normalizedGifts := make([]model.WeekStarActivityGiftConfig, 0, len(req.GiftConfigs)) + for index, item := range req.GiftConfigs { + if item.GiftID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_gift_id", "giftId is required") + } + if _, exists := giftIDs[item.GiftID]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_gift_id", "giftConfigs must not contain duplicate gifts") + } + giftIDs[item.GiftID] = struct{}{} + id, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + normalizedGifts = append(normalizedGifts, model.WeekStarActivityGiftConfig{ + ID: id, + GiftID: item.GiftID, + GiftName: strings.TrimSpace(item.GiftName), + GiftPhoto: strings.TrimSpace(item.GiftPhoto), + GiftCandy: item.GiftCandy, + Sort: index + 1, + }) + } + + rewardRanks := map[int]struct{}{1: {}, 2: {}, 3: {}} + seenRanks := make(map[int]struct{}, len(req.RewardConfigs)) + normalizedRewards := make([]model.WeekStarActivityRewardConfig, 0, len(req.RewardConfigs)) + for _, item := range req.RewardConfigs { + if _, ok := rewardRanks[item.Rank]; !ok { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_rank", "reward rank must be 1, 2, or 3") + } + if item.RewardGroupID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "rewardGroupId is required") + } + if _, exists := seenRanks[item.Rank]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks") + } + seenRanks[item.Rank] = struct{}{} + id, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{ + ID: id, + Rank: item.Rank, + RewardGroupID: item.RewardGroupID, + RewardGroupName: strings.TrimSpace(item.RewardGroupName), + }) + } + + sort.Slice(normalizedRewards, func(i, j int) bool { + return normalizedRewards[i].Rank < normalizedRewards[j].Rank + }) + + var savedConfigID int64 + // 主配置与子配置在同一个事务中写入,避免出现半保存状态。 + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + storedStartAt := s.toStorageWallClock(startAt) + storedEndAt := s.toStorageWallClock(endAt) + var configRow model.WeekStarActivityConfig + if req.ID > 0 { + if err := tx.Where("id = ?", req.ID).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "week_star_config_not_found", "config not found") + } else if err != nil { + return err + } + if !s.isConfigEditable(&configRow, time.Now().In(s.location)) { + return NewAppError(http.StatusBadRequest, "week_star_config_readonly", "only future-week configs can be edited") + } + if strings.TrimSpace(req.SysOrigin) == "" { + sysOrigin = configRow.SysOrigin + } + } else { + configID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.WeekStarActivityConfig{ + ID: configID, + SysOrigin: sysOrigin, + Enabled: req.Enabled, + StartAt: storedStartAt, + EndAt: storedEndAt, + Timezone: timezone, + CreateTime: now, + UpdateTime: now, + } + } + + if req.Enabled { + if err := s.ensureNoEnabledOverlap(ctx, tx, sysOrigin, configRow.ID, storedStartAt, storedEndAt); err != nil { + return err + } + } + + configRow.SysOrigin = sysOrigin + configRow.Enabled = req.Enabled + configRow.StartAt = storedStartAt + configRow.EndAt = storedEndAt + configRow.Timezone = timezone + configRow.UpdateTime = now + + if req.ID > 0 { + if err := tx.Model(&model.WeekStarActivityConfig{}). + Where("id = ?", configRow.ID). + Updates(map[string]any{ + "sys_origin": configRow.SysOrigin, + "enabled": configRow.Enabled, + "start_at": configRow.StartAt, + "end_at": configRow.EndAt, + "timezone": configRow.Timezone, + "update_time": configRow.UpdateTime, + }).Error; err != nil { + return err + } + } else if err := tx.Create(&configRow).Error; err != nil { + return err + } + + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WeekStarActivityGiftConfig{}).Error; err != nil { + return err + } + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WeekStarActivityRewardConfig{}).Error; err != nil { + return err + } + for index := range normalizedGifts { + normalizedGifts[index].ConfigID = configRow.ID + normalizedGifts[index].CreateTime = now + normalizedGifts[index].UpdateTime = now + } + for index := range normalizedRewards { + normalizedRewards[index].ConfigID = configRow.ID + normalizedRewards[index].CreateTime = now + normalizedRewards[index].UpdateTime = now + } + if err := tx.Create(&normalizedGifts).Error; err != nil { + return err + } + if err := tx.Create(&normalizedRewards).Error; err != nil { + return err + } + savedConfigID = configRow.ID + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, sysOrigin, savedConfigID) +} + +// buildConfigResponse 把配置主表和子表组装成后台接口需要的展示结构。 +func (s *WeekStarService) buildConfigResponse(ctx context.Context, configRow *model.WeekStarActivityConfig, giftRows []model.WeekStarActivityGiftConfig, rewardRows []model.WeekStarActivityRewardConfig, includeItems bool) (*WeekStarConfigResponse, error) { + rewards := make([]WeekStarRewardConfigPayload, 0, len(rewardRows)) + sort.Slice(rewardRows, func(i, j int) bool { + return rewardRows[i].Rank < rewardRows[j].Rank + }) + for _, row := range rewardRows { + payload := WeekStarRewardConfigPayload{ + Rank: row.Rank, + RewardGroupID: row.RewardGroupID, + RewardGroupName: row.RewardGroupName, + } + if includeItems && row.RewardGroupID > 0 { + items, groupName, err := s.loadRewardPreviewItems(ctx, row.RewardGroupID) + if err == nil { + payload.Items = items + if strings.TrimSpace(groupName) != "" { + payload.RewardGroupName = groupName + } + } + } + rewards = append(rewards, payload) + } + now := time.Now().In(s.location) + return &WeekStarConfigResponse{ + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Status: s.resolveActivityStatus(configRow, now), + Editable: s.isConfigEditable(configRow, now), + StartAt: formatDateTime(s.fromStorageWallClock(configRow.StartAt)), + EndAt: formatDateTime(s.fromStorageWallClock(configRow.EndAt)), + Timezone: configRow.Timezone, + UpdateTime: formatDateTime(configRow.UpdateTime), + GiftConfigs: buildGiftPayloads(giftRows), + RewardConfigs: rewards, + }, nil +} + +// buildRewardPreview 把奖励配置组装成 H5 可直接渲染的奖励预览数据。 +func (s *WeekStarService) buildRewardPreview(ctx context.Context, rewardRows []model.WeekStarActivityRewardConfig) ([]WeekStarRewardConfigPayload, error) { + sort.Slice(rewardRows, func(i, j int) bool { + return rewardRows[i].Rank < rewardRows[j].Rank + }) + result := make([]WeekStarRewardConfigPayload, 0, len(rewardRows)) + for _, row := range rewardRows { + payload := WeekStarRewardConfigPayload{ + Rank: row.Rank, + RewardGroupID: row.RewardGroupID, + RewardGroupName: row.RewardGroupName, + } + if row.RewardGroupID > 0 { + items, groupName, err := s.loadRewardPreviewItems(ctx, row.RewardGroupID) + if err == nil { + payload.Items = items + if strings.TrimSpace(groupName) != "" { + payload.RewardGroupName = groupName + } + } + } + result = append(result, payload) + } + return result, nil +} + +// loadRewardPreviewItems 从 Java 奖励组详情接口拉取预览物品列表。 +func (s *WeekStarService) loadRewardPreviewItems(ctx context.Context, rewardGroupID int64) ([]WeekStarRewardPreviewItem, string, error) { + detail, err := s.java.GetRewardGroupDetail(ctx, rewardGroupID) + if err != nil { + return nil, "", err + } + items := make([]WeekStarRewardPreviewItem, 0, len(detail.RewardConfigList)) + for _, item := range detail.RewardConfigList { + items = append(items, WeekStarRewardPreviewItem{ + ID: int64(item.ID), + Type: item.Type, + Name: item.Name, + Content: item.Content, + Quantity: int64(item.Quantity), + Cover: item.Cover, + Remark: item.Remark, + }) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, detail.Name, nil +} + +// loadConfigBundleByID 按配置 ID 读取完整配置,并校验它是否属于目标系统。 +func (s *WeekStarService) loadConfigBundleByID(ctx context.Context, id int64, sysOrigin string) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { + if id <= 0 { + return nil, nil, nil, nil + } + var configRow model.WeekStarActivityConfig + err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil + } + if err != nil { + return nil, nil, nil, err + } + if strings.TrimSpace(sysOrigin) != "" && configRow.SysOrigin != s.normalizeSysOrigin(sysOrigin) { + return nil, nil, nil, nil + } + gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) + if err != nil { + return nil, nil, nil, err + } + return &configRow, gifts, rewards, nil +} + +// loadPreferredAdminConfigBundle 优先选择当前周配置,其次选择最近未来周配置,再回退到最新一条配置。 +func (s *WeekStarService) loadPreferredAdminConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { + if configRow, gifts, rewards, err := s.loadActiveConfigBundle(ctx, sysOrigin, at); err != nil || configRow != nil { + return configRow, gifts, rewards, err + } + if configRow, gifts, rewards, err := s.loadUpcomingConfigBundle(ctx, sysOrigin, at); err != nil || configRow != nil { + return configRow, gifts, rewards, err + } + sysOrigin = s.normalizeSysOrigin(sysOrigin) + var configRow model.WeekStarActivityConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + Order("start_at desc"). + Order("id desc"). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil + } + if err != nil { + return nil, nil, nil, err + } + gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) + if err != nil { + return nil, nil, nil, err + } + return &configRow, gifts, rewards, nil +} + +// loadActiveConfigBundle 根据事件时间或当前时间查找正在生效的启用配置,时间窗语义为 [startAt, endAt)。 +func (s *WeekStarService) loadActiveConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + storedAt := s.toStorageWallClock(at) + var configRow model.WeekStarActivityConfig + err := s.repo.DB.WithContext(ctx). + Where( + "sys_origin = ? AND enabled = ? AND start_at <= ? AND end_at > ?", + sysOrigin, + true, + storedAt, + storedAt, + ). + Order("start_at asc"). + Order("id asc"). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil + } + if err != nil { + return nil, nil, nil, err + } + gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) + if err != nil { + return nil, nil, nil, err + } + return &configRow, gifts, rewards, nil +} + +// loadUpcomingConfigBundle 查找最近的未来周启用配置,用于当前周结束后的自动切换展示。 +func (s *WeekStarService) loadUpcomingConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + var configRow model.WeekStarActivityConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND enabled = ? AND start_at > ?", sysOrigin, true, s.toStorageWallClock(at)). + Order("start_at asc"). + Order("id asc"). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil + } + if err != nil { + return nil, nil, nil, err + } + gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) + if err != nil { + return nil, nil, nil, err + } + return &configRow, gifts, rewards, nil +} + +// ensureNoEnabledOverlap 校验同一系统下启用状态的配置时间窗不重叠。 +func (s *WeekStarService) ensureNoEnabledOverlap(ctx context.Context, tx *gorm.DB, sysOrigin string, excludeID int64, startAt, endAt time.Time) error { + query := tx.WithContext(ctx). + Model(&model.WeekStarActivityConfig{}). + Where("sys_origin = ? AND enabled = ?", sysOrigin, true). + Where("start_at < ? AND end_at > ?", endAt, startAt) + if excludeID > 0 { + query = query.Where("id <> ?", excludeID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return err + } + if count > 0 { + return NewAppError(http.StatusBadRequest, "week_star_config_overlap", "enabled configs must not overlap in time range") + } + return nil +} + +// isConfigEditable 判断配置是否仍处于未开始状态,只有未来周配置允许后台编辑。 +func (s *WeekStarService) isConfigEditable(configRow *model.WeekStarActivityConfig, now time.Time) bool { + if configRow == nil { + return false + } + return s.fromStorageWallClock(configRow.StartAt).After(now.In(s.location)) +} + +// loadConfigChildren 读取某个活动配置下的指定礼物和奖励组子表。 +func (s *WeekStarService) loadConfigChildren(ctx context.Context, configID int64) ([]model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { + var gifts []model.WeekStarActivityGiftConfig + if err := s.repo.DB.WithContext(ctx). + Where("config_id = ?", configID). + Order("sort asc"). + Find(&gifts).Error; err != nil { + return nil, nil, err + } + var rewards []model.WeekStarActivityRewardConfig + if err := s.repo.DB.WithContext(ctx). + Where("config_id = ?", configID). + Order("`rank` asc"). + Find(&rewards).Error; err != nil { + return nil, nil, err + } + return gifts, rewards, nil +} + +// resolveActivityStatus 根据当前时间判断配置处于未开始、进行中、已结束或已关闭状态。 +func (s *WeekStarService) resolveActivityStatus(configRow *model.WeekStarActivityConfig, now time.Time) string { + if configRow == nil || !configRow.Enabled { + return weekStarActivityStatusDisabled + } + startAt := s.fromStorageWallClock(configRow.StartAt) + endAt := s.fromStorageWallClock(configRow.EndAt) + switch { + case now.Before(startAt): + return weekStarActivityStatusNotStarted + case !now.Before(endAt): + return weekStarActivityStatusEnded + default: + return weekStarActivityStatusOngoing + } +} diff --git a/internal/service/weekstar/cycle.go b/internal/service/weekstar/cycle.go new file mode 100644 index 0000000..f522a13 --- /dev/null +++ b/internal/service/weekstar/cycle.go @@ -0,0 +1,95 @@ +package weekstar + +import ( + "fmt" + "strings" + "time" + + "chatapp3-golang/internal/model" +) + +// referenceTimeForPage 返回页面应参考的周期时间点,未开始取 startAt,已结束取结束前一秒。 +func (s *WeekStarService) referenceTimeForPage(configRow *model.WeekStarActivityConfig, now time.Time) time.Time { + if configRow == nil { + return now + } + startAt := s.fromStorageWallClock(configRow.StartAt) + endAt := s.fromStorageWallClock(configRow.EndAt) + switch { + case now.Before(startAt): + return startAt + case !now.Before(endAt): + return endAt.Add(-time.Second) + default: + return now + } +} + +// cycleBounds 返回某个时间所在周的起止边界。 +func (s *WeekStarService) cycleBounds(t time.Time) (time.Time, time.Time) { + start := s.cycleStart(t) + return start, start.AddDate(0, 0, 7) +} + +// cycleStart 计算某个时间所在周的周一 00:00,作为周榜周期起点。 +func (s *WeekStarService) cycleStart(t time.Time) time.Time { + local := t.In(s.location) + weekday := int(local.Weekday()) + if weekday == 0 { + weekday = 7 + } + startDay := local.AddDate(0, 0, -(weekday - 1)) + return time.Date(startDay.Year(), startDay.Month(), startDay.Day(), 0, 0, 0, 0, s.location) +} + +// cycleKey 把周期开始时间格式化为稳定的周期标识,例如 20260414。 +func (s *WeekStarService) cycleKey(start time.Time) string { + return start.In(s.location).Format("20060102") +} + +// toStorageWallClock 把活动时区时间转换为数据库约定的墙上时间存储格式。 +func (s *WeekStarService) toStorageWallClock(t time.Time) time.Time { + local := t.In(s.location) + return time.Date( + local.Year(), + local.Month(), + local.Day(), + local.Hour(), + local.Minute(), + local.Second(), + local.Nanosecond(), + s.location, + ) +} + +// fromStorageWallClock 把数据库里的墙上时间恢复到活动时区,便于业务计算和展示。 +func (s *WeekStarService) fromStorageWallClock(t time.Time) time.Time { + return time.Date( + t.Year(), + t.Month(), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + t.Nanosecond(), + s.location, + ) +} + +// rankRedisKey 返回某个活动某个周期的实时排行 Redis Key。 +func (s *WeekStarService) rankRedisKey(configID int64, cycleKey string) string { + return fmt.Sprintf("week-star:rank:%d:%s", configID, cycleKey) +} + +// settleLockKey 返回某个活动某个周期的结算锁 Redis Key。 +func (s *WeekStarService) settleLockKey(configID int64, cycleKey string) string { + return fmt.Sprintf("week-star:settle-lock:%d:%s", configID, cycleKey) +} + +// normalizeSysOrigin 兜底系统标识为空时使用默认系统,并统一转成大写。 +func (s *WeekStarService) normalizeSysOrigin(sysOrigin string) string { + if strings.TrimSpace(sysOrigin) == "" { + return strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)) + } + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} diff --git a/internal/service/weekstar/event.go b/internal/service/weekstar/event.go new file mode 100644 index 0000000..67c3682 --- /dev/null +++ b/internal/service/weekstar/event.go @@ -0,0 +1,108 @@ +package weekstar + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" +) + +// ProcessGiftEvent 按送礼事件发生时间归属配置,并把积分累加到对应周期实时榜。 +func (s *WeekStarService) ProcessGiftEvent(ctx context.Context, event weekStarGiftEvent, rawPayload string) error { + sysOrigin := s.normalizeSysOrigin(event.SysOrigin) + if sysOrigin != s.normalizeSysOrigin(s.cfg.WeekStar.DefaultSysOrigin) { + return nil + } + trackID := strings.TrimSpace(event.TrackID) + if trackID == "" || int64(event.SendUserID) <= 0 { + return nil + } + eventTime := resolveMillisTime(int64(event.CreateTime), time.Now()).In(s.location) + configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, eventTime) + if err != nil { + return err + } + if configRow == nil || !configRow.Enabled { + return nil + } + giftID := int64(event.GiftConfig.ID) + if giftID <= 0 || !containsGiftID(giftRows, giftID) { + return nil + } + // 先按活动口径计算本次送礼积分,再写审计日志和实时榜。 + scoreGold := calculateGiftScore(event) + if scoreGold <= 0 { + return nil + } + acceptUserSize := int64(countDistinctAcceptUsers(event.Accepts)) + if acceptUserSize <= 0 { + acceptUserSize = 1 + } + + cycleStart, _ := s.cycleBounds(eventTime) + cycleKey := s.cycleKey(cycleStart) + logID, err := utils.NextID() + if err != nil { + return err + } + now := time.Now() + record := model.WeekStarGiftLog{ + ID: logID, + ConfigID: configRow.ID, + CycleKey: cycleKey, + TrackID: trackID, + SendUserID: int64(event.SendUserID), + GiftID: giftID, + Quantity: int64(event.Quantity), + AcceptUserSize: acceptUserSize, + ScoreGold: scoreGold, + EventTime: eventTime, + Status: weekStarGiftLogStatusPending, + RawPayload: truncateString(rawPayload, 65535), + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { + if isDuplicateKeyErr(err) { + return nil + } + return err + } + + if err := s.repo.Redis.ZIncrBy(ctx, s.rankRedisKey(configRow.ID, cycleKey), float64(scoreGold), strconv.FormatInt(record.SendUserID, 10)).Err(); err != nil { + // Redis 加分失败时把日志标成失败,方便补偿与排查。 + _ = s.repo.DB.WithContext(context.Background()). + Model(&model.WeekStarGiftLog{}). + Where("id = ?", record.ID). + Updates(map[string]any{ + "status": weekStarGiftLogStatusFailed, + "update_time": time.Now(), + }).Error + return err + } + + return s.repo.DB.WithContext(ctx). + Model(&model.WeekStarGiftLog{}). + Where("id = ?", record.ID). + Updates(map[string]any{ + "status": weekStarGiftLogStatusSuccess, + "update_time": time.Now(), + }).Error +} + +// ProcessGiftPayload 解析送礼消息原文并复用周榜统计逻辑,方便本地压测和非 RocketMQ 调用。 +func (s *WeekStarService) ProcessGiftPayload(ctx context.Context, payload string) error { + payload = strings.TrimSpace(payload) + if payload == "" { + return nil + } + var event weekStarGiftEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return err + } + return s.ProcessGiftEvent(ctx, event, payload) +} diff --git a/internal/service/weekstar/lifecycle.go b/internal/service/weekstar/lifecycle.go new file mode 100644 index 0000000..bce6d22 --- /dev/null +++ b/internal/service/weekstar/lifecycle.go @@ -0,0 +1,103 @@ +package weekstar + +import ( + "context" + "encoding/json" + "errors" + "log" + "strings" + + rmq "github.com/apache/rocketmq-clients/golang/v5" + "github.com/apache/rocketmq-clients/golang/v5/credentials" +) + +// StartMessageConsumer 启动送礼消息消费链路。 +func (s *WeekStarService) StartMessageConsumer(ctx context.Context) error { + return s.startRocketMQConsumer(ctx) +} + +// startRocketMQConsumer 启动 RocketMQ 推送消费者,订阅送礼事件。 +func (s *WeekStarService) startRocketMQConsumer(ctx context.Context) error { + if !s.cfg.WeekStar.RocketMQ.Enabled { + log.Printf("week star rocketmq consumer disabled") + return nil + } + if strings.TrimSpace(s.cfg.WeekStar.RocketMQ.Endpoint) == "" || + strings.TrimSpace(s.cfg.WeekStar.RocketMQ.AccessKey) == "" || + strings.TrimSpace(s.cfg.WeekStar.RocketMQ.AccessSecret) == "" { + log.Printf("week star rocketmq consumer skipped: endpoint or credentials missing") + return nil + } + + filter := rmq.SUB_ALL + if tag := strings.TrimSpace(s.cfg.WeekStar.RocketMQ.Tag); tag != "" { + filter = rmq.NewFilterExpression(tag) + } + + consumer, err := rmq.NewPushConsumer(&rmq.Config{ + Endpoint: s.cfg.WeekStar.RocketMQ.Endpoint, + NameSpace: s.cfg.WeekStar.RocketMQ.Namespace, + ConsumerGroup: s.cfg.WeekStar.RocketMQ.ConsumerGroup, + Credentials: &credentials.SessionCredentials{ + AccessKey: s.cfg.WeekStar.RocketMQ.AccessKey, + AccessSecret: s.cfg.WeekStar.RocketMQ.AccessSecret, + SecurityToken: s.cfg.WeekStar.RocketMQ.SecurityToken, + }, + }, + rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{ + s.cfg.WeekStar.RocketMQ.Topic: filter, + }), + rmq.WithPushConsumptionThreadCount(8), + rmq.WithPushMaxCacheMessageCount(256), + rmq.WithPushMessageListener(&rmq.FuncMessageListener{ + Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult { + if messageView == nil { + return rmq.SUCCESS + } + // 单条消息处理失败时返回 FAILURE,让 RocketMQ 触发重试。 + if err := s.processRocketMQMessage(context.Background(), messageView); err != nil { + log.Printf("week star 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("week star rocketmq consumer started. topic=%s group=%s tag=%s", + s.cfg.WeekStar.RocketMQ.Topic, + s.cfg.WeekStar.RocketMQ.ConsumerGroup, + s.cfg.WeekStar.RocketMQ.Tag, + ) + go func() { + <-ctx.Done() + if s.rocketConsumer != nil { + _ = s.rocketConsumer.GracefulStop() + } + }() + return nil +} + +// processRocketMQMessage 解析 RocketMQ 消息体并交给业务逻辑处理。 +func (s *WeekStarService) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error { + payload := strings.TrimSpace(string(messageView.GetBody())) + if payload == "" { + return nil + } + if err := s.ProcessGiftPayload(ctx, payload); err != nil { + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) { + log.Printf("drop malformed week star message. messageId=%s err=%v", messageView.GetMessageId(), err) + return nil + } + return err + } + return nil +} diff --git a/internal/service/weekstar/mapper.go b/internal/service/weekstar/mapper.go new file mode 100644 index 0000000..a9c6fca --- /dev/null +++ b/internal/service/weekstar/mapper.go @@ -0,0 +1,232 @@ +package weekstar + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" +) + +// buildGiftPayloads 把礼物配置表转换成对外载体。 +func buildGiftPayloads(rows []model.WeekStarActivityGiftConfig) []WeekStarGiftConfigPayload { + sort.Slice(rows, func(i, j int) bool { + return rows[i].Sort < rows[j].Sort + }) + result := make([]WeekStarGiftConfigPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, WeekStarGiftConfigPayload{ + GiftID: row.GiftID, + GiftName: row.GiftName, + GiftPhoto: row.GiftPhoto, + GiftCandy: row.GiftCandy, + Sort: row.Sort, + }) + } + return result +} + +// calculateGiftScore 按活动口径计算一笔送礼的金币积分。 +func calculateGiftScore(event weekStarGiftEvent) int64 { + if actual := int64(event.GiftValue.ActualAmount); actual > 0 { + return actual + } + acceptUserSize := int64(countDistinctAcceptUsers(event.Accepts)) + if acceptUserSize <= 0 { + acceptUserSize = 1 + } + quantity := int64(event.Quantity) + if quantity <= 0 { + quantity = 1 + } + giftCandy := int64(event.GiftConfig.GiftCandy) + if giftCandy <= 0 { + return 0 + } + return giftCandy * quantity * acceptUserSize +} + +// countDistinctAcceptUsers 统计不同收礼用户数。 +func countDistinctAcceptUsers(users []weekStarGiftEventUser) int { + if len(users) == 0 { + return 0 + } + set := make(map[int64]struct{}, len(users)) + for _, user := range users { + if int64(user.AcceptUserID) <= 0 { + continue + } + set[int64(user.AcceptUserID)] = struct{}{} + } + return len(set) +} + +// containsGiftID 判断礼物是否命中活动指定礼物列表。 +func containsGiftID(rows []model.WeekStarActivityGiftConfig, giftID int64) bool { + for _, row := range rows { + if row.GiftID == giftID { + return true + } + } + return false +} + +// parseRedisMemberInt64 把 Redis member 统一解析为用户 ID。 +func parseRedisMemberInt64(member any) (int64, bool) { + switch value := member.(type) { + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed, err == nil + case []byte: + parsed, err := strconv.ParseInt(strings.TrimSpace(string(value)), 10, 64) + return parsed, err == nil + case int64: + return value, true + case int: + return int64(value), true + default: + return 0, false + } +} + +// parseDateTimeInLocation 按指定时区解析后台传入的时间字符串。 +func parseDateTimeInLocation(value string, location *time.Location) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("time is required") + } + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + time.RFC3339, + "2006-01-02", + } + for _, layout := range layouts { + if layout == time.RFC3339 { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.In(location), nil + } + continue + } + if parsed, err := time.ParseInLocation(layout, value, location); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported time format: %s", value) +} + +// resolveMillisTime 把毫秒时间戳解析为时间,失败时回退默认值。 +func resolveMillisTime(raw int64, fallback time.Time) time.Time { + if raw <= 0 { + return fallback + } + if raw < 1_000_000_000_000 { + return time.Unix(raw, 0) + } + return time.UnixMilli(raw) +} + +// normalizePage 规范分页参数,避免非法页码或 pageSize。 +func normalizePage(cursor, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +// formatDateTime 把时间格式化成统一展示字符串。 +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +// formatPeriodText 把周期起止时间格式化成页面展示文案。 +func formatPeriodText(start, end time.Time) string { + if start.IsZero() || end.IsZero() || !end.After(start) { + return "" + } + return start.Format("2006/01/02") + "-" + end.Format("2006/01/02") +} + +// truncateString 截断字符串,避免超出字段长度限制。 +func truncateString(value string, limit int) string { + if limit <= 0 || len(value) <= limit { + return value + } + return value[:limit] +} + +// maxTime 返回两个时间中的较大值。 +func maxTime(left, right time.Time) time.Time { + if left.After(right) { + return left + } + return right +} + +// minTime 返回两个时间中的较小值。 +func minTime(left, right time.Time) time.Time { + if left.Before(right) { + return left + } + return right +} + +// maxInt64 返回两个整数中的较大值。 +func maxInt64(left, right int64) int64 { + if left > right { + return left + } + return right +} + +// isDuplicateKeyErr 判断是否是数据库唯一键冲突错误。 +func isDuplicateKeyErr(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "duplicate entry") || strings.Contains(message, "duplicate key") +} + +// toWeekStarRewardRecordView 把发奖记录模型转换成后台展示对象。 +func toWeekStarRewardRecordView(row model.WeekStarRewardRecord) WeekStarRewardRecordView { + return WeekStarRewardRecordView{ + ID: row.ID, + ConfigID: row.ConfigID, + CycleKey: row.CycleKey, + UserID: row.UserID, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + Account: row.Account, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + Rank: row.Rank, + RewardGroupID: row.RewardGroupID, + RewardGroupName: row.RewardGroupName, + ScoreGold: row.ScoreGold, + Status: row.Status, + RetryCount: row.RetryCount, + LastError: row.LastError, + SentAt: formatDateTime(pointerTimeValue(row.SentAt)), + UpdateTime: formatDateTime(row.UpdateTime), + } +} + +// pointerTimeValue 把可空时间指针转换成非空时间值。 +func pointerTimeValue(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return *value +} diff --git a/internal/service/weekstar/page.go b/internal/service/weekstar/page.go new file mode 100644 index 0000000..c520427 --- /dev/null +++ b/internal/service/weekstar/page.go @@ -0,0 +1,368 @@ +package weekstar + +import ( + "context" + "errors" + "math" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// GetPublicPage 返回 H5 聚合页数据,优先读取当前生效配置,其次回退到最近未来周配置。 +func (s *WeekStarService) GetPublicPage(ctx context.Context, sysOrigin string) (*WeekStarPageResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + now := time.Now().In(s.location) + + activeConfig, giftRows, rewardRows, err := s.loadActiveConfigBundle(ctx, sysOrigin, now) + if err != nil { + return nil, err + } + + activityStatus := weekStarActivityStatusDisabled + displayConfig := activeConfig + if displayConfig == nil { + displayConfig, giftRows, rewardRows, err = s.loadUpcomingConfigBundle(ctx, sysOrigin, now) + if err != nil { + return nil, err + } + if displayConfig == nil { + return &WeekStarPageResponse{ + ActivityStatus: weekStarActivityStatusDisabled, + QualifyingGifts: []WeekStarGiftConfigPayload{}, + LastWeekTop3: []WeekStarRankingUserView{}, + ThisWeekTop10: []WeekStarRankingUserView{}, + RewardPreview: []WeekStarRewardConfigPayload{}, + CurrentPeriodText: "", + LastPeriodText: "", + CountdownMillis: 0, + }, nil + } + activityStatus = weekStarActivityStatusNotStarted + } else { + activityStatus = weekStarActivityStatusOngoing + } + + // 页面优先围绕当前生效周期展示;未开始时改为围绕 startAt 推导周期。 + referenceTime := now + if activityStatus == weekStarActivityStatusNotStarted { + referenceTime = s.fromStorageWallClock(displayConfig.StartAt) + } + cycleStart, cycleEnd := s.cycleBounds(referenceTime) + currentPeriodStart := maxTime(cycleStart, s.fromStorageWallClock(displayConfig.StartAt)) + currentPeriodEnd := minTime(cycleEnd, s.fromStorageWallClock(displayConfig.EndAt)) + + // 历史前三优先走最近一次已结算快照,避免受实时榜单波动影响。 + lastAnchor, err := s.loadLatestSettledCycle(ctx, sysOrigin, currentPeriodStart) + if err != nil { + return nil, err + } + lastWeekTop3 := []WeekStarRankingUserView{} + lastPeriodText := "" + if lastAnchor != nil { + lastWeekTop3, err = s.listSnapshotRanking(ctx, lastAnchor.ConfigID, lastAnchor.CycleKey) + if err != nil { + return nil, err + } + lastPeriodText = formatPeriodText( + s.fromStorageWallClock(lastAnchor.PeriodStartAt), + s.fromStorageWallClock(lastAnchor.PeriodEndAt), + ) + } + + // 本周榜只在活动进行中展示实时榜。 + thisWeekTop10 := []WeekStarRankingUserView{} + if activityStatus == weekStarActivityStatusOngoing { + thisWeekTop10, err = s.loadLiveRanking(ctx, displayConfig.ID, s.cycleKey(cycleStart), 10) + if err != nil { + return nil, err + } + } + + rewardPreview, err := s.buildRewardPreview(ctx, rewardRows) + if err != nil { + return nil, err + } + + countdownTarget := currentPeriodEnd + if activityStatus == weekStarActivityStatusNotStarted { + countdownTarget = s.fromStorageWallClock(displayConfig.StartAt) + } + countdownMillis := maxInt64(0, countdownTarget.UnixMilli()-now.UnixMilli()) + + return &WeekStarPageResponse{ + ActivityStatus: activityStatus, + CurrentPeriodText: formatPeriodText(currentPeriodStart, currentPeriodEnd), + LastPeriodText: lastPeriodText, + CountdownMillis: countdownMillis, + QualifyingGifts: buildGiftPayloads(giftRows), + LastWeekTop3: lastWeekTop3, + ThisWeekTop10: thisWeekTop10, + RewardPreview: rewardPreview, + }, nil +} + +// PageSnapshots 按系统或配置分页查询历史快照,支持跨多周活动查看冻结榜单。 +func (s *WeekStarService) PageSnapshots(ctx context.Context, sysOrigin string, configID int64, cycleKey string, cursor, limit int) (map[string]any, error) { + page, size := normalizePage(cursor, limit) + query := s.repo.DB.WithContext(ctx). + Table("week_star_rank_snapshot AS snapshot"). + Joins("JOIN week_star_activity_config AS config ON config.id = snapshot.config_id") + + if configID > 0 { + query = query.Where("snapshot.config_id = ?", configID) + } else { + query = query.Where("config.sys_origin = ?", s.normalizeSysOrigin(sysOrigin)) + } + if strings.TrimSpace(cycleKey) != "" { + query = query.Where("snapshot.cycle_key = ?", strings.TrimSpace(cycleKey)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.WeekStarRankSnapshot + if total > 0 { + if err := query. + Select("snapshot.*"). + Order("snapshot.period_end_at desc"). + Order("snapshot.rank asc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + } + + records := make([]WeekStarSnapshotView, 0, len(rows)) + for _, row := range rows { + records = append(records, WeekStarSnapshotView{ + ID: row.ID, + ConfigID: row.ConfigID, + CycleKey: row.CycleKey, + PeriodStartAt: formatDateTime(s.fromStorageWallClock(row.PeriodStartAt)), + PeriodEndAt: formatDateTime(s.fromStorageWallClock(row.PeriodEndAt)), + UserID: row.UserID, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + Account: row.Account, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + Rank: row.Rank, + ScoreGold: row.ScoreGold, + }) + } + return map[string]any{"records": records, "total": total}, nil +} + +// PageRewardRecords 按系统或配置分页查询发奖记录,支持失败补发前的筛选和定位。 +func (s *WeekStarService) PageRewardRecords(ctx context.Context, sysOrigin string, configID int64, cycleKey, status string, cursor, limit int) (map[string]any, error) { + page, size := normalizePage(cursor, limit) + query := s.repo.DB.WithContext(ctx). + Table("week_star_reward_record AS record"). + Joins("JOIN week_star_activity_config AS config ON config.id = record.config_id") + + if configID > 0 { + query = query.Where("record.config_id = ?", configID) + } else { + query = query.Where("config.sys_origin = ?", s.normalizeSysOrigin(sysOrigin)) + } + if strings.TrimSpace(cycleKey) != "" { + query = query.Where("record.cycle_key = ?", strings.TrimSpace(cycleKey)) + } + if strings.TrimSpace(status) != "" { + query = query.Where("record.status = ?", strings.ToUpper(strings.TrimSpace(status))) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.WeekStarRewardRecord + if total > 0 { + if err := query. + Select("record.*"). + Order("record.create_time desc"). + Order("record.rank asc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + } + + records := make([]WeekStarRewardRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toWeekStarRewardRecordView(row)) + } + return map[string]any{"records": records, "total": total}, nil +} + +// RetryRewardRecord 对失败的发奖记录提交一次后台补发任务。 +func (s *WeekStarService) RetryRewardRecord(ctx context.Context, id int64) (*WeekStarRewardRecordView, error) { + if id <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_id", "id is required") + } + var record model.WeekStarRewardRecord + err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(http.StatusNotFound, "reward_record_not_found", "reward record not found") + } + if err != nil { + return nil, err + } + if record.Status != weekStarRewardStatusFailed { + return nil, NewAppError(http.StatusBadRequest, "reward_record_not_retryable", "only failed records can be retried") + } + if err := s.enqueueRewardRetryTask(ctx, record.ID); err != nil { + return nil, err + } + result := toWeekStarRewardRecordView(record) + return &result, nil +} + +// listSnapshotRanking 读取某个配置某个周期已经冻结的榜单快照。 +func (s *WeekStarService) listSnapshotRanking(ctx context.Context, configID int64, cycleKey string) ([]WeekStarRankingUserView, error) { + if strings.TrimSpace(cycleKey) == "" { + return []WeekStarRankingUserView{}, nil + } + var rows []model.WeekStarRankSnapshot + if err := s.repo.DB.WithContext(ctx). + Where("config_id = ? AND cycle_key = ?", configID, cycleKey). + Order("`rank` asc"). + Find(&rows).Error; err != nil { + return nil, err + } + result := make([]WeekStarRankingUserView, 0, len(rows)) + for _, row := range rows { + result = append(result, WeekStarRankingUserView{ + UserID: row.UserID, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + Account: row.Account, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + Rank: row.Rank, + ScoreGold: row.ScoreGold, + }) + } + return result, nil +} + +// loadLiveRanking 从 Redis 实时榜读取前 N 名,并补齐用户资料用于 H5 和结算展示。 +func (s *WeekStarService) loadLiveRanking(ctx context.Context, configID int64, cycleKey string, limit int64) ([]WeekStarRankingUserView, error) { + if limit <= 0 { + return []WeekStarRankingUserView{}, nil + } + zs, err := s.repo.Redis.ZRevRangeWithScores(ctx, s.rankRedisKey(configID, cycleKey), 0, limit-1).Result() + if err != nil && !errors.Is(err, redis.Nil) { + return nil, err + } + if len(zs) == 0 { + return []WeekStarRankingUserView{}, nil + } + + userIDs := make([]int64, 0, len(zs)) + rankScores := make([]weekStarRankScore, 0, len(zs)) + for index, item := range zs { + userID, ok := parseRedisMemberInt64(item.Member) + if !ok || userID <= 0 { + continue + } + userIDs = append(userIDs, userID) + rankScores = append(rankScores, weekStarRankScore{ + UserID: userID, + ScoreGold: int64(math.Round(item.Score)), + Rank: index + 1, + }) + } + profileMap, err := s.lookupUserProfiles(ctx, userIDs) + if err != nil { + return nil, err + } + + result := make([]WeekStarRankingUserView, 0, len(rankScores)) + for _, item := range rankScores { + profile := profileMap[item.UserID] + result = append(result, WeekStarRankingUserView{ + UserID: item.UserID, + UserAvatar: profile.UserAvatar, + UserNickname: profile.UserNickname, + Account: profile.Account, + CountryCode: profile.CountryCode, + CountryName: profile.CountryName, + Rank: item.Rank, + ScoreGold: item.ScoreGold, + }) + } + return result, nil +} + +// lookupUserProfiles 优先查本地资料,查不到时回源 Java 用户资料接口兜底。 +func (s *WeekStarService) lookupUserProfiles(ctx context.Context, userIDs []int64) (map[int64]weekStarUserProfile, error) { + result := make(map[int64]weekStarUserProfile, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + var rows []model.UserBaseInfo + if err := s.repo.DB.WithContext(ctx).Where("id IN ?", userIDs).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.ID] = weekStarUserProfile{ + UserID: row.ID, + Account: row.Account, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + } + } + for _, userID := range userIDs { + if _, exists := result[userID]; exists { + continue + } + profile, err := s.java.GetUserProfile(ctx, userID) + if err != nil { + result[userID] = weekStarUserProfile{UserID: userID} + continue + } + result[userID] = weekStarUserProfile{ + UserID: userID, + UserAvatar: profile.UserAvatar, + UserNickname: profile.UserNickname, + } + } + return result, nil +} + +// loadLatestSettledCycle 读取当前展示配置之前最近一次已结算周期,用于展示历史前三。 +func (s *WeekStarService) loadLatestSettledCycle(ctx context.Context, sysOrigin string, before time.Time) (*weekStarSettledCycleAnchor, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + var anchor weekStarSettledCycleAnchor + result := s.repo.DB.WithContext(ctx). + Table("week_star_rank_snapshot AS snapshot"). + Select("snapshot.config_id, snapshot.cycle_key, snapshot.period_start_at, snapshot.period_end_at"). + Joins("JOIN week_star_activity_config AS config ON config.id = snapshot.config_id"). + Where("config.sys_origin = ?", sysOrigin). + Where("snapshot.period_end_at <= ?", s.toStorageWallClock(before)). + Order("snapshot.period_end_at desc"). + Order("snapshot.config_id desc"). + Limit(1). + Scan(&anchor) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, nil + } + return &anchor, nil +} diff --git a/internal/service/weekstar/retry_task.go b/internal/service/weekstar/retry_task.go new file mode 100644 index 0000000..ceef5c2 --- /dev/null +++ b/internal/service/weekstar/retry_task.go @@ -0,0 +1,48 @@ +package weekstar + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +const weekStarRewardRetryPendingTTL = 30 * time.Minute + +// enqueueRewardRetryTask 把失败发奖记录投递到 Redis Stream,交给 chatapp-cron 执行真正补发。 +func (s *WeekStarService) enqueueRewardRetryTask(ctx context.Context, recordID int64) error { + streamKey := strings.TrimSpace(s.cfg.WeekStar.RetryStreamKey) + if streamKey == "" { + return NewAppError(http.StatusInternalServerError, "retry_stream_not_configured", "week star retry stream is not configured") + } + + pendingKey := s.rewardRetryPendingKey(recordID) + acquired, err := s.repo.Redis.SetNX(ctx, pendingKey, "1", weekStarRewardRetryPendingTTL).Result() + if err != nil { + return err + } + if !acquired { + // 已经存在待处理补发任务时直接视为幂等成功,避免重复投递。 + return nil + } + + _, err = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{ + Stream: streamKey, + Values: map[string]any{ + "recordId": recordID, + }, + }).Result() + if err != nil { + _, _ = s.repo.Redis.Del(context.Background(), pendingKey).Result() + return err + } + return nil +} + +// rewardRetryPendingKey 返回补发任务去重 key,避免同一条记录被重复提交。 +func (s *WeekStarService) rewardRetryPendingKey(recordID int64) string { + return strings.TrimSpace(s.cfg.WeekStar.RetryStreamKey) + ":pending:" + strconv.FormatInt(recordID, 10) +} diff --git a/internal/service/weekstar/types.go b/internal/service/weekstar/types.go new file mode 100644 index 0000000..ea31381 --- /dev/null +++ b/internal/service/weekstar/types.go @@ -0,0 +1,277 @@ +package weekstar + +import ( + "context" + "math" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + rmq "github.com/apache/rocketmq-clients/golang/v5" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + // 周榜送礼日志和发奖记录状态常量。 + weekStarGiftLogStatusPending = "PENDING" + weekStarGiftLogStatusSuccess = "SUCCESS" + weekStarGiftLogStatusFailed = "FAILED" + + weekStarRewardStatusPending = "PENDING" + weekStarRewardStatusSuccess = "SUCCESS" + weekStarRewardStatusFailed = "FAILED" + + weekStarActivityStatusDisabled = "DISABLED" + weekStarActivityStatusNotStarted = "NOT_STARTED" + weekStarActivityStatusOngoing = "ONGOING" + weekStarActivityStatusEnded = "ENDED" +) + +// flexibleInt64 用于兼容 MQ 中字符串、整数、浮点数混用的数字字段。 +type flexibleInt64 int64 + +// UnmarshalJSON 把多种 JSON 数字表现形式统一解析成 int64。 +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = 0 + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + raw = strings.Trim(raw, "\"") + } + if raw == "" { + *v = 0 + return nil + } + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil { + *v = flexibleInt64(parsed) + return nil + } + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + return err + } + *v = flexibleInt64(int64(math.Round(parsed))) + return nil +} + +// weekStarGiftEvent 表示送礼 MQ 事件的核心字段。 +type weekStarGiftEvent struct { + TrackID string `json:"trackId"` + SysOrigin string `json:"sysOrigin"` + SendUserID flexibleInt64 `json:"sendUserId"` + Quantity flexibleInt64 `json:"quantity"` + CreateTime flexibleInt64 `json:"createTime"` + Accepts []weekStarGiftEventUser `json:"accepts"` + GiftConfig weekStarGiftEventGift `json:"giftConfig"` + GiftValue weekStarGiftEventValue `json:"giftValue"` +} + +// weekStarGiftEventUser 表示单个收礼人信息。 +type weekStarGiftEventUser struct { + AcceptUserID flexibleInt64 `json:"acceptUserId"` +} + +// weekStarGiftEventGift 表示礼物配置快照。 +type weekStarGiftEventGift struct { + ID flexibleInt64 `json:"id"` + GiftCandy flexibleInt64 `json:"giftCandy"` + GiftName string `json:"giftName"` + GiftPhoto string `json:"giftPhoto"` +} + +// weekStarGiftEventValue 表示本次送礼最终价值快照。 +type weekStarGiftEventValue struct { + ActualAmount flexibleInt64 `json:"actualAmount"` +} + +// WeekStarGiftConfigPayload 是周榜礼物配置的对外载体。 +type WeekStarGiftConfigPayload struct { + GiftID int64 `json:"giftId"` + GiftName string `json:"giftName"` + GiftPhoto string `json:"giftPhoto"` + GiftCandy int64 `json:"giftCandy"` + Sort int `json:"sort,omitempty"` +} + +// WeekStarRewardPreviewItem 表示奖励预览中的单个物品。 +type WeekStarRewardPreviewItem struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Quantity int64 `json:"quantity"` + Cover string `json:"cover"` + Remark string `json:"remark,omitempty"` +} + +// WeekStarRewardConfigPayload 是单个名次奖励配置的对外载体。 +type WeekStarRewardConfigPayload struct { + Rank int `json:"rank"` + RewardGroupID int64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` + Items []WeekStarRewardPreviewItem `json:"items,omitempty"` +} + +// WeekStarConfigResponse 是后台单个活动配置的读模型,同时复用于配置列表项。 +type WeekStarConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Editable bool `json:"editable"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Timezone string `json:"timezone"` + UpdateTime string `json:"updateTime"` + GiftConfigs []WeekStarGiftConfigPayload `json:"giftConfigs"` + RewardConfigs []WeekStarRewardConfigPayload `json:"rewardConfigs"` +} + +// SaveWeekStarConfigRequest 是周榜配置保存入参;带 ID 表示更新,不带 ID 表示创建未来周配置。 +type SaveWeekStarConfigRequest struct { + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Timezone string `json:"timezone"` + GiftConfigs []WeekStarGiftConfigPayload `json:"giftConfigs"` + RewardConfigs []WeekStarRewardConfigPayload `json:"rewardConfigs"` +} + +// WeekStarRankingUserView 是榜单用户展示对象。 +type WeekStarRankingUserView struct { + UserID int64 `json:"userId"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + Account string `json:"account"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + Rank int `json:"rank"` + ScoreGold int64 `json:"scoreGold"` +} + +// WeekStarPageResponse 是 H5 聚合页接口返回体。 +type WeekStarPageResponse struct { + ActivityStatus string `json:"activityStatus"` + CurrentPeriodText string `json:"currentPeriodText"` + LastPeriodText string `json:"lastPeriodText"` + CountdownMillis int64 `json:"countdownMillis"` + QualifyingGifts []WeekStarGiftConfigPayload `json:"qualifyingGifts"` + LastWeekTop3 []WeekStarRankingUserView `json:"lastWeekTop3"` + ThisWeekTop10 []WeekStarRankingUserView `json:"thisWeekTop10"` + RewardPreview []WeekStarRewardConfigPayload `json:"rewardPreview"` +} + +// WeekStarSnapshotView 是后台快照列表的展示对象。 +type WeekStarSnapshotView struct { + ID int64 `json:"id"` + ConfigID int64 `json:"configId"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + UserID int64 `json:"userId"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + Account string `json:"account"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + Rank int `json:"rank"` + ScoreGold int64 `json:"scoreGold"` +} + +// WeekStarRewardRecordView 是后台发奖记录列表的展示对象。 +type WeekStarRewardRecordView struct { + ID int64 `json:"id"` + ConfigID int64 `json:"configId"` + CycleKey string `json:"cycleKey"` + UserID int64 `json:"userId"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + Account string `json:"account"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + Rank int `json:"rank"` + RewardGroupID int64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` + ScoreGold int64 `json:"scoreGold"` + Status string `json:"status"` + RetryCount int `json:"retryCount"` + LastError string `json:"lastError"` + SentAt string `json:"sentAt"` + UpdateTime string `json:"updateTime"` +} + +// WeekStarRetryRewardRequest 是后台补发请求入参。 +type WeekStarRetryRewardRequest struct { + ID int64 `json:"id"` +} + +// weekStarUserProfile 是内部使用的轻量用户资料视图。 +type weekStarUserProfile struct { + UserID int64 + Account string + UserAvatar string + UserNickname string + CountryCode string + CountryName string +} + +// weekStarRankScore 保存 Redis 榜单归一化后的名次和分值。 +type weekStarRankScore struct { + UserID int64 + ScoreGold int64 + Rank int +} + +// weekStarSettledCycleAnchor 描述某个系统最近一次已结算的周期,用于跨配置回溯历史前三。 +type weekStarSettledCycleAnchor struct { + ConfigID int64 + CycleKey string + PeriodStartAt time.Time + PeriodEndAt time.Time +} + +type weekStarDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type weekStarGateway interface { + GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) +} + +type weekStarPorts struct { + DB weekStarDB + Redis redis.Cmdable +} + +// WeekStarService 负责周榜配置、实时积分、H5 聚合页和后台补发任务投递。 +type WeekStarService struct { + cfg config.Config + repo weekStarPorts + java weekStarGateway + location *time.Location + rocketConsumer rmq.PushConsumer +} + +// NewWeekStarService 创建指定礼物周榜服务,并初始化统一使用的活动时区。 +func NewWeekStarService(cfg config.Config, db weekStarDB, cache redis.Cmdable, javaGateway weekStarGateway) *WeekStarService { + location, err := time.LoadLocation(strings.TrimSpace(cfg.WeekStar.Timezone)) + if err != nil { + location = time.UTC + } + return &WeekStarService{ + cfg: cfg, + repo: weekStarPorts{DB: db, Redis: cache}, + java: javaGateway, + location: location, + } +} diff --git a/internal/util/id.go b/internal/utils/id.go similarity index 95% rename from internal/util/id.go rename to internal/utils/id.go index 7af10d8..453e3f9 100644 --- a/internal/util/id.go +++ b/internal/utils/id.go @@ -1,4 +1,4 @@ -package util +package utils import ( "sync" diff --git a/internal/utils/json.go b/internal/utils/json.go new file mode 100644 index 0000000..b22c38e --- /dev/null +++ b/internal/utils/json.go @@ -0,0 +1,12 @@ +package utils + +import "encoding/json" + +// MustJSONString 把对象转换成 JSON;失败时返回调用方指定的回退字符串。 +func MustJSONString(value any, fallback string) string { + raw, err := json.Marshal(value) + if err != nil { + return fallback + } + return string(raw) +} diff --git a/migrations/001_invite_campaign.sql b/migrations/001_invite_campaign.sql index e440590..934752d 100644 --- a/migrations/001_invite_campaign.sql +++ b/migrations/001_invite_campaign.sql @@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS invite_campaign_config ( anti_cheat_same_device_once TINYINT(1) NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_user BIGINT DEFAULT NULL, + update_user BIGINT DEFAULT NULL, UNIQUE KEY uk_invite_campaign_sys_origin (sys_origin) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -25,6 +27,8 @@ CREATE TABLE IF NOT EXISTS invite_campaign_reward_rule ( sort 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, + create_user BIGINT DEFAULT NULL, + update_user BIGINT DEFAULT NULL, KEY idx_invite_rule_sys_type_sort (sys_origin, rule_type, sort) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/003_week_star_activity.sql b/migrations/003_week_star_activity.sql new file mode 100644 index 0000000..83c011c --- /dev/null +++ b/migrations/003_week_star_activity.sql @@ -0,0 +1,104 @@ +CREATE TABLE IF NOT EXISTS week_star_activity_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + start_at DATETIME NOT NULL, + end_at DATETIME NOT NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_week_star_sys_origin_window (sys_origin, start_at, end_at), + KEY idx_week_star_active_lookup (sys_origin, enabled, start_at, end_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS week_star_activity_gift_config ( + id BIGINT NOT NULL PRIMARY KEY, + config_id BIGINT NOT NULL, + gift_id BIGINT NOT NULL, + gift_name VARCHAR(255) DEFAULT NULL, + gift_photo VARCHAR(1024) DEFAULT NULL, + gift_candy BIGINT NOT NULL DEFAULT 0, + sort 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, + UNIQUE KEY uk_week_star_gift_sort (config_id, sort), + KEY idx_week_star_gift_config (config_id, gift_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS week_star_activity_reward_config ( + id BIGINT NOT NULL PRIMARY KEY, + config_id BIGINT NOT NULL, + `rank` INT NOT NULL, + reward_group_id BIGINT NOT NULL, + reward_group_name VARCHAR(255) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_week_star_reward_rank (config_id, `rank`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS week_star_gift_log ( + id BIGINT NOT NULL PRIMARY KEY, + config_id BIGINT NOT NULL, + cycle_key VARCHAR(32) NOT NULL, + track_id VARCHAR(128) NOT NULL, + send_user_id BIGINT NOT NULL, + gift_id BIGINT NOT NULL, + quantity BIGINT NOT NULL DEFAULT 0, + accept_user_size BIGINT NOT NULL DEFAULT 0, + score_gold BIGINT NOT NULL DEFAULT 0, + event_time DATETIME NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS', + raw_payload LONGTEXT DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_week_star_track_id (track_id), + KEY idx_week_star_gift_log_cycle (config_id, cycle_key), + KEY idx_week_star_gift_log_sender (send_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS week_star_rank_snapshot ( + id BIGINT NOT NULL PRIMARY KEY, + config_id BIGINT NOT NULL, + cycle_key VARCHAR(32) NOT NULL, + `rank` INT NOT NULL, + period_start_at DATETIME NOT NULL, + period_end_at DATETIME NOT NULL, + user_id BIGINT NOT NULL, + account VARCHAR(64) DEFAULT NULL, + user_avatar VARCHAR(1024) DEFAULT NULL, + user_nickname VARCHAR(255) DEFAULT NULL, + country_code VARCHAR(32) DEFAULT NULL, + country_name VARCHAR(128) DEFAULT NULL, + score_gold BIGINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_week_star_snapshot_rank (config_id, cycle_key, `rank`), + KEY idx_week_star_snapshot_cycle (config_id, cycle_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS week_star_reward_record ( + id BIGINT NOT NULL PRIMARY KEY, + config_id BIGINT NOT NULL, + cycle_key VARCHAR(32) NOT NULL, + `rank` INT NOT NULL, + user_id BIGINT NOT NULL, + account VARCHAR(64) DEFAULT NULL, + user_avatar VARCHAR(1024) DEFAULT NULL, + user_nickname VARCHAR(255) DEFAULT NULL, + country_code VARCHAR(32) DEFAULT NULL, + country_name VARCHAR(128) DEFAULT NULL, + reward_group_id BIGINT NOT NULL, + reward_group_name VARCHAR(255) DEFAULT NULL, + score_gold BIGINT NOT NULL DEFAULT 0, + track_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + last_error VARCHAR(1024) DEFAULT NULL, + sent_at DATETIME DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_week_star_reward_record_rank (config_id, cycle_key, `rank`), + KEY idx_week_star_reward_record_cycle (config_id, cycle_key), + KEY idx_week_star_reward_record_status (status), + KEY idx_week_star_reward_record_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/004_lucky_gift.sql b/migrations/004_lucky_gift.sql new file mode 100644 index 0000000..95412f4 --- /dev/null +++ b/migrations/004_lucky_gift.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS lucky_gift_draw_request ( + id BIGINT NOT NULL PRIMARY KEY, + business_id VARCHAR(64) NOT NULL, + consume_asset_record_id VARCHAR(64) DEFAULT NULL, + sys_origin VARCHAR(32) NOT NULL, + standard_id BIGINT NOT NULL, + room_id BIGINT NOT NULL, + send_user_id BIGINT NOT NULL, + gift_id BIGINT NOT NULL, + gift_price BIGINT NOT NULL DEFAULT 0, + gift_num BIGINT NOT NULL DEFAULT 0, + gift_is_free TINYINT(1) NOT NULL DEFAULT 0, + provider_code INT NOT NULL DEFAULT 0, + reward_total BIGINT NOT NULL DEFAULT 0, + balance_after BIGINT NOT NULL DEFAULT 0, + wallet_event_id VARCHAR(128) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + error_message VARCHAR(1024) DEFAULT NULL, + request_json LONGTEXT DEFAULT NULL, + provider_response_json LONGTEXT DEFAULT NULL, + response_json LONGTEXT DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_lucky_gift_business (business_id), + UNIQUE KEY uk_lucky_gift_wallet_event (wallet_event_id), + KEY idx_lucky_gift_sender (sys_origin, send_user_id), + KEY idx_lucky_gift_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS lucky_gift_draw_order ( + id BIGINT NOT NULL PRIMARY KEY, + business_id VARCHAR(64) NOT NULL, + order_seed VARCHAR(64) NOT NULL, + sort_no INT NOT NULL DEFAULT 0, + accept_user_id BIGINT NOT NULL, + provider_order_id VARCHAR(64) NOT NULL, + is_win TINYINT(1) NOT NULL DEFAULT 0, + reward_num BIGINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_lucky_gift_order (business_id, order_seed), + KEY idx_lucky_gift_order_business (business_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/005_register_reward_daily_limit.sql b/migrations/005_register_reward_daily_limit.sql new file mode 100644 index 0000000..9560614 --- /dev/null +++ b/migrations/005_register_reward_daily_limit.sql @@ -0,0 +1,100 @@ +CREATE TABLE IF NOT EXISTS `resident_register_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量', + `reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID', + `daily_limit` bigint NOT NULL DEFAULT '0' COMMENT '每日发奖份数限制,0表示不限', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + `create_user` bigint DEFAULT NULL COMMENT '创建用户', + `update_user` bigint DEFAULT NULL COMMENT '更新用户', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_resident_register_reward_sys_origin` (`sys_origin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻注册奖励配置'; + +CREATE TABLE IF NOT EXISTS `resident_register_reward_log` ( + `id` bigint NOT NULL COMMENT '主键ID', + `event_id` varchar(128) NOT NULL COMMENT '注册事件ID', + `stream_message_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Redis Stream消息ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `claim_date` varchar(10) NOT NULL DEFAULT '' COMMENT '领取日期,格式 YYYY-MM-DD', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量', + `reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID', + `grant_mode` varchar(32) NOT NULL DEFAULT '' COMMENT '发奖决策模式:ISSUE_REWARD/QUOTA_EXHAUSTED', + `status` varchar(32) NOT NULL COMMENT '处理状态', + `error_message` varchar(255) NOT NULL DEFAULT '' COMMENT '错误信息', + `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_register_reward_event` (`event_id`), + KEY `idx_register_reward_sys_user` (`sys_origin`, `user_id`), + KEY `idx_register_reward_claim` (`sys_origin`, `claim_date`, `grant_mode`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻注册奖励处理日志'; + +SET @current_schema := DATABASE(); + +SET @add_daily_limit_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'resident_register_reward_config' + AND column_name = 'daily_limit' + ), + 'ALTER TABLE `resident_register_reward_config` ADD COLUMN `daily_limit` bigint NOT NULL DEFAULT 0 COMMENT ''每日发奖份数限制,0表示不限'' AFTER `reward_group_id`', + 'SELECT 1' +); + +PREPARE add_daily_limit_stmt FROM @add_daily_limit_sql; +EXECUTE add_daily_limit_stmt; +DEALLOCATE PREPARE add_daily_limit_stmt; + +SET @add_claim_date_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'resident_register_reward_log' + AND column_name = 'claim_date' + ), + 'ALTER TABLE `resident_register_reward_log` ADD COLUMN `claim_date` varchar(10) NOT NULL DEFAULT '''' COMMENT ''领取日期,格式 YYYY-MM-DD'' AFTER `user_id`', + 'SELECT 1' +); + +PREPARE add_claim_date_stmt FROM @add_claim_date_sql; +EXECUTE add_claim_date_stmt; +DEALLOCATE PREPARE add_claim_date_stmt; + +SET @add_grant_mode_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'resident_register_reward_log' + AND column_name = 'grant_mode' + ), + 'ALTER TABLE `resident_register_reward_log` ADD COLUMN `grant_mode` varchar(32) NOT NULL DEFAULT '''' COMMENT ''发奖决策模式:ISSUE_REWARD/QUOTA_EXHAUSTED'' AFTER `reward_group_id`', + 'SELECT 1' +); + +PREPARE add_grant_mode_stmt FROM @add_grant_mode_sql; +EXECUTE add_grant_mode_stmt; +DEALLOCATE PREPARE add_grant_mode_stmt; + +SET @add_claim_index_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'resident_register_reward_log' + AND index_name = 'idx_register_reward_claim' + ), + 'ALTER TABLE `resident_register_reward_log` ADD KEY `idx_register_reward_claim` (`sys_origin`, `claim_date`, `grant_mode`)', + 'SELECT 1' +); + +PREPARE add_claim_index_stmt FROM @add_claim_index_sql; +EXECUTE add_claim_index_stmt; +DEALLOCATE PREPARE add_claim_index_stmt; diff --git a/scripts/lucky_gift_java_go_live_smoke.example.json b/scripts/lucky_gift_java_go_live_smoke.example.json new file mode 100644 index 0000000..d434cb2 --- /dev/null +++ b/scripts/lucky_gift_java_go_live_smoke.example.json @@ -0,0 +1,92 @@ +{ + "goBaseURL": "http://127.0.0.1:2900", + "javaBaseURL": "http://127.0.0.1:2400", + "replayFuseOnly": true, + "scenarios": [ + { + "name": "1人付费", + "expectProviderCode": 200, + "javaEvent": { + "businessId": "1900012345678900101", + "consumeAssetRecordId": "1900012345678900101", + "sysOrigin": "LIKEI", + "standardId": 2044375642107080706, + "roomId": 2043938895665598465, + "userId": 4569421795454615552, + "giftId": 2044387508480962561, + "giftPrice": 100, + "quantity": 1, + "giftIsFree": false, + "checkCombo": false, + "giftCombos": 0, + "users": [ + { "id": "1900012345678901101", "acceptUserId": 4569421795454615552, "giftsCount": 0, "comboLossCount": 0 } + ] + } + }, + { + "name": "多人同送", + "expectProviderCode": 200, + "javaEvent": { + "businessId": "1900012345678900102", + "consumeAssetRecordId": "1900012345678900102", + "sysOrigin": "LIKEI", + "standardId": 2044375642107080706, + "roomId": 2043938895665598465, + "userId": 4569421795454615552, + "giftId": 2044387508480962561, + "giftPrice": 100, + "quantity": 1, + "giftIsFree": false, + "checkCombo": false, + "giftCombos": 0, + "users": [ + { "id": "1900012345678901201", "acceptUserId": 4569421795454615552, "giftsCount": 0, "comboLossCount": 0 }, + { "id": "1900012345678901202", "acceptUserId": 4569421795454615552, "giftsCount": 0, "comboLossCount": 0 } + ] + } + }, + { + "name": "免费礼物", + "expectProviderCode": 200, + "javaEvent": { + "businessId": "1900012345678900103", + "consumeAssetRecordId": "1900012345678900103", + "sysOrigin": "LIKEI", + "standardId": 2044375642107080706, + "roomId": 2043938895665598465, + "userId": 4569421795454615552, + "giftId": 2044387508480962561, + "giftPrice": 100, + "quantity": 1, + "giftIsFree": true, + "checkCombo": false, + "giftCombos": 0, + "users": [ + { "id": "1900012345678901301", "acceptUserId": 4569421795454615552, "giftsCount": 0, "comboLossCount": 0 } + ] + } + }, + { + "name": "501熔断", + "expectProviderCode": 501, + "javaEvent": { + "businessId": "1900012345678900104", + "consumeAssetRecordId": "1900012345678900104", + "sysOrigin": "LIKEI", + "standardId": 2044375642107080706, + "roomId": 2043938895665598465, + "userId": 4569421795454615552, + "giftId": 2044387508480962561, + "giftPrice": 100, + "quantity": 1, + "giftIsFree": false, + "checkCombo": false, + "giftCombos": 0, + "users": [ + { "id": "1900012345678901401", "acceptUserId": 4569421795454615552, "giftsCount": 0, "comboLossCount": 0 } + ] + } + } + ] +} diff --git a/scripts/lucky_gift_live_smoke.example.json b/scripts/lucky_gift_live_smoke.example.json new file mode 100644 index 0000000..8c781a3 --- /dev/null +++ b/scripts/lucky_gift_live_smoke.example.json @@ -0,0 +1,27 @@ +{ + "goBaseURL": "http://127.0.0.1:2900", + "javaBaseURL": "http://127.0.0.1:2400", + "internalToken": "", + "replayFuseOnly": true, + "scenarios": [ + { + "name": "1人付费", + "expectProviderCode": 200, + "request": { + "businessId": "1900012345678900001", + "consumeAssetRecordId": "18889990001", + "sysOrigin": "LIKEI", + "standardId": 18816, + "roomId": 12345, + "sendUserId": 67890, + "giftId": 111, + "giftPrice": 150, + "giftNum": 1, + "giftIsFree": false, + "orders": [ + { "id": "90001", "acceptUserId": 2001 } + ] + } + } + ] +} diff --git a/scripts/run_lucky_gift_e2e.sh b/scripts/run_lucky_gift_e2e.sh new file mode 100755 index 0000000..e48480d --- /dev/null +++ b/scripts/run_lucky_gift_e2e.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_ROOT}" +go run ./cmd/lucky-gift-e2e "$@" diff --git a/scripts/run_lucky_gift_java_go_live_smoke.sh b/scripts/run_lucky_gift_java_go_live_smoke.sh new file mode 100755 index 0000000..5709bd7 --- /dev/null +++ b/scripts/run_lucky_gift_java_go_live_smoke.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_ROOT}" +go run ./cmd/lucky-gift-live-smoke --java-base-url "${REAL_LUCKY_GIFT_JAVA_BASE_URL:-http://127.0.0.1:2400}" "$@" diff --git a/scripts/run_lucky_gift_live_smoke.sh b/scripts/run_lucky_gift_live_smoke.sh new file mode 100755 index 0000000..b3f2ff5 --- /dev/null +++ b/scripts/run_lucky_gift_live_smoke.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_ROOT}" +go run ./cmd/lucky-gift-live-smoke --discover "$@"