feat: refactor modules and add lucky gift services

This commit is contained in:
hy001 2026-04-18 18:53:18 +08:00
parent 2e48d6dc67
commit 0db85586be
89 changed files with 10707 additions and 4149 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
.DS_Store
target/
/api
/consumer

View File

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

39
cmd/consumer/main.go Normal file
View File

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

45
go.mod
View File

@ -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
)

434
go.sum
View File

@ -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=

View File

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

View File

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

View File

@ -0,0 +1,9 @@
package common
// AuthUser 表示已经完成鉴权的调用方用户。
type AuthUser struct {
UserID int64
SysOrigin string
Token string
Authorization string
}

View File

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

View File

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

View File

@ -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())
}

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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())
}

View File

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

View File

@ -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(),
})
}

63
internal/router/router.go Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
package baishun
import "chatapp3-golang/internal/common"
type AuthUser = common.AuthUser
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

@ -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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"`
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package luckygift
import "chatapp3-golang/internal/common"
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
}
}

View File

@ -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 "发放中"
}
}

View File

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

View File

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

View File

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

View File

@ -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 &registerRewardNoticeHub{
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)
}

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
}
}

View File

@ -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(),
}
}

View File

@ -0,0 +1,7 @@
package weekstar
import "chatapp3-golang/internal/common"
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package util
package utils
import (
"sync"

12
internal/utils/json.go Normal file
View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

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

8
scripts/run_lucky_gift_e2e.sh Executable file
View File

@ -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 "$@"

View File

@ -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}" "$@"

View File

@ -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 "$@"