feat: refactor modules and add lucky gift services
This commit is contained in:
parent
2e48d6dc67
commit
0db85586be
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
.DS_Store
|
||||
target/
|
||||
/api
|
||||
/consumer
|
||||
|
||||
@ -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
39
cmd/consumer/main.go
Normal 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
45
go.mod
@ -3,43 +3,70 @@ module chatapp3-golang
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/apache/rocketmq-clients/golang/v5 v5.1.3
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
contrib.go.opencensus.io/exporter/ocagent v0.7.0 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dchest/siphash v1.2.3 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/api v0.230.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||
google.golang.org/grpc v1.72.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
)
|
||||
|
||||
434
go.sum
434
go.sum
@ -1,3 +1,36 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
contrib.go.opencensus.io/exporter/ocagent v0.7.0 h1:BEfdCTXfMV30tLZD8c9n64V/tIZX5+9sXiuFLnrr1k8=
|
||||
contrib.go.opencensus.io/exporter/ocagent v0.7.0/go.mod h1:IshRmMJBhDfFj5Y67nVhMYTTIze91RUeT73ipWKs/GY=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/apache/rocketmq-clients/golang/v5 v5.1.3 h1:ooj+E/fX6oSKEABCHdMglxcQvFIde5VSwdwnP2Zph7s=
|
||||
github.com/apache/rocketmq-clients/golang/v5 v5.1.3/go.mod h1:qg/POLGOcuU33gPbi2yA6Ak4kTPydBBamrQU+bl0WMU=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@ -8,68 +41,172 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
|
||||
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@ -81,32 +218,289 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.25.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM=
|
||||
google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f h1:tjZsroqekhC63+WMqzmWyW5Twj/ZfR5HAlpd5YQ1Vs0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
|
||||
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
36
internal/bootstrap/bootstrap.go
Normal file
36
internal/bootstrap/bootstrap.go
Normal 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
|
||||
}
|
||||
18
internal/common/app_error.go
Normal file
18
internal/common/app_error.go
Normal 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}
|
||||
}
|
||||
9
internal/common/auth_user.go
Normal file
9
internal/common/auth_user.go
Normal file
@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
// AuthUser 表示已经完成鉴权的调用方用户。
|
||||
type AuthUser struct {
|
||||
UserID int64
|
||||
SysOrigin string
|
||||
Token string
|
||||
Authorization string
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
101
internal/config/config_test.go
Normal file
101
internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
214
internal/integration/gateways.go
Normal file
214
internal/integration/gateways.go
Normal 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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" }
|
||||
|
||||
28
internal/model/common_models.go
Normal file
28
internal/model/common_models.go
Normal 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" }
|
||||
150
internal/model/invite_models.go
Normal file
150
internal/model/invite_models.go
Normal 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" }
|
||||
49
internal/model/luckygift_models.go
Normal file
49
internal/model/luckygift_models.go
Normal 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" }
|
||||
@ -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" }
|
||||
40
internal/model/registerreward_models.go
Normal file
40
internal/model/registerreward_models.go
Normal 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" }
|
||||
118
internal/model/weekstar_models.go
Normal file
118
internal/model/weekstar_models.go
Normal 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" }
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
77
internal/router/invite_routes.go
Normal file
77
internal/router/invite_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
33
internal/router/lucky_gift_routes.go
Normal file
33
internal/router/lucky_gift_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
122
internal/router/middleware.go
Normal file
122
internal/router/middleware.go
Normal 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())
|
||||
}
|
||||
110
internal/router/register_reward_routes.go
Normal file
110
internal/router/register_reward_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
56
internal/router/response.go
Normal file
56
internal/router/response.go
Normal 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
63
internal/router/router.go
Normal 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"})
|
||||
})
|
||||
}
|
||||
108
internal/router/week_star_routes.go
Normal file
108
internal/router/week_star_routes.go
Normal 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
8
internal/service/baishun/aliases.go
Normal file
8
internal/service/baishun/aliases.go
Normal file
@ -0,0 +1,8 @@
|
||||
package baishun
|
||||
|
||||
import "chatapp3-golang/internal/common"
|
||||
|
||||
type AuthUser = common.AuthUser
|
||||
type AppError = common.AppError
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
408
internal/service/baishun/callback.go
Normal file
408
internal/service/baishun/callback.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
131
internal/service/baishun/catalog.go
Normal file
131
internal/service/baishun/catalog.go
Normal 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
|
||||
}
|
||||
131
internal/service/baishun/convert.go
Normal file
131
internal/service/baishun/convert.go
Normal 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)
|
||||
}
|
||||
}
|
||||
105
internal/service/baishun/game_row.go
Normal file
105
internal/service/baishun/game_row.go
Normal 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
|
||||
}
|
||||
263
internal/service/baishun/launch.go
Normal file
263
internal/service/baishun/launch.go
Normal 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
|
||||
}
|
||||
232
internal/service/baishun/room.go
Normal file
232
internal/service/baishun/room.go
Normal 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
|
||||
}
|
||||
319
internal/service/baishun/types.go
Normal file
319
internal/service/baishun/types.go
Normal 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
314
internal/service/invite/bind.go
Normal file
314
internal/service/invite/bind.go
Normal 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
|
||||
}
|
||||
172
internal/service/invite/home.go
Normal file
172
internal/service/invite/home.go
Normal 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
|
||||
}
|
||||
45
internal/service/invite/landing.go
Normal file
45
internal/service/invite/landing.go
Normal 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
|
||||
}
|
||||
55
internal/service/invite/normalize.go
Normal file
55
internal/service/invite/normalize.go
Normal 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]
|
||||
}
|
||||
302
internal/service/invite/recharge.go
Normal file
302
internal/service/invite/recharge.go
Normal 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)
|
||||
}
|
||||
210
internal/service/invite/reward.go
Normal file
210
internal/service/invite/reward.go
Normal 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"
|
||||
}
|
||||
}
|
||||
207
internal/service/invite/store.go
Normal file
207
internal/service/invite/store.go
Normal 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
|
||||
}
|
||||
167
internal/service/invite/task.go
Normal file
167
internal/service/invite/task.go
Normal 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
|
||||
}
|
||||
224
internal/service/invite/types.go
Normal file
224
internal/service/invite/types.go
Normal 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,
|
||||
}
|
||||
}
|
||||
7
internal/service/luckygift/aliases.go
Normal file
7
internal/service/luckygift/aliases.go
Normal file
@ -0,0 +1,7 @@
|
||||
package luckygift
|
||||
|
||||
import "chatapp3-golang/internal/common"
|
||||
|
||||
type AppError = common.AppError
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
280
internal/service/luckygift/draw.go
Normal file
280
internal/service/luckygift/draw.go
Normal 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
|
||||
}
|
||||
335
internal/service/luckygift/provider.go
Normal file
335
internal/service/luckygift/provider.go
Normal 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
|
||||
}
|
||||
149
internal/service/luckygift/service_test.go
Normal file
149
internal/service/luckygift/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
208
internal/service/luckygift/store.go
Normal file
208
internal/service/luckygift/store.go
Normal 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
|
||||
}
|
||||
104
internal/service/luckygift/types.go
Normal file
104
internal/service/luckygift/types.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
254
internal/service/registerreward/admin.go
Normal file
254
internal/service/registerreward/admin.go
Normal 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 "发放中"
|
||||
}
|
||||
}
|
||||
202
internal/service/registerreward/consumer.go
Normal file
202
internal/service/registerreward/consumer.go
Normal 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
|
||||
}
|
||||
108
internal/service/registerreward/content.go
Normal file
108
internal/service/registerreward/content.go
Normal 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
|
||||
}
|
||||
44
internal/service/registerreward/content_test.go
Normal file
44
internal/service/registerreward/content_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
106
internal/service/registerreward/debug.go
Normal file
106
internal/service/registerreward/debug.go
Normal 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 ®isterRewardNoticeHub{
|
||||
subscribers: make(map[int64]map[chan RegisterRewardNoticeEvent]struct{}),
|
||||
recent: make(map[int64][]RegisterRewardNoticeEvent),
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe 按 userId 订阅注册奖励通知镜像。
|
||||
func (h *registerRewardNoticeHub) subscribe(userID int64) (<-chan RegisterRewardNoticeEvent, func()) {
|
||||
stream := make(chan RegisterRewardNoticeEvent, 16)
|
||||
|
||||
h.mu.Lock()
|
||||
recent := append([]RegisterRewardNoticeEvent(nil), h.recent[userID]...)
|
||||
if _, ok := h.subscribers[userID]; !ok {
|
||||
h.subscribers[userID] = make(map[chan RegisterRewardNoticeEvent]struct{})
|
||||
}
|
||||
h.subscribers[userID][stream] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, event := range recent {
|
||||
select {
|
||||
case stream <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
cancel := func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
group, ok := h.subscribers[userID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := group[stream]; exists {
|
||||
delete(group, stream)
|
||||
close(stream)
|
||||
}
|
||||
|
||||
if len(group) == 0 {
|
||||
delete(h.subscribers, userID)
|
||||
}
|
||||
}
|
||||
|
||||
return stream, cancel
|
||||
}
|
||||
|
||||
// publish 把通知镜像广播给当前用户的所有本地订阅者。
|
||||
// 这里使用非阻塞写入,避免测试页断开或消费变慢时反向卡住正式发奖链路。
|
||||
func (h *registerRewardNoticeHub) publish(event RegisterRewardNoticeEvent) {
|
||||
if event.UserID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.recent[event.UserID] = append(h.recent[event.UserID], event)
|
||||
if len(h.recent[event.UserID]) > 10 {
|
||||
h.recent[event.UserID] = append([]RegisterRewardNoticeEvent(nil), h.recent[event.UserID][len(h.recent[event.UserID])-10:]...)
|
||||
}
|
||||
group := h.subscribers[event.UserID]
|
||||
targets := make([]chan RegisterRewardNoticeEvent, 0, len(group))
|
||||
for stream := range group {
|
||||
targets = append(targets, stream)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, stream := range targets {
|
||||
select {
|
||||
case stream <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeDebugNotices 给本地测试页提供注册奖励通知订阅能力。
|
||||
func (s *RegisterRewardService) SubscribeDebugNotices(userID int64) (<-chan RegisterRewardNoticeEvent, func()) {
|
||||
if s == nil || s.debug == nil {
|
||||
closed := make(chan RegisterRewardNoticeEvent)
|
||||
close(closed)
|
||||
return closed, func() {}
|
||||
}
|
||||
return s.debug.subscribe(userID)
|
||||
}
|
||||
|
||||
// publishDebugNotice 把刚刚触发的 REGISTER_REWARD_GRANTED 镜像到本地调试流。
|
||||
func (s *RegisterRewardService) publishDebugNotice(event RegisterRewardNoticeEvent) {
|
||||
if s == nil || s.debug == nil {
|
||||
return
|
||||
}
|
||||
s.debug.publish(event)
|
||||
}
|
||||
33
internal/service/registerreward/debug_test.go
Normal file
33
internal/service/registerreward/debug_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
80
internal/service/registerreward/notify.go
Normal file
80
internal/service/registerreward/notify.go
Normal 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
|
||||
}
|
||||
129
internal/service/registerreward/quota.go
Normal file
129
internal/service/registerreward/quota.go
Normal 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()
|
||||
}
|
||||
46
internal/service/registerreward/quota_test.go
Normal file
46
internal/service/registerreward/quota_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
170
internal/service/registerreward/reward.go
Normal file
170
internal/service/registerreward/reward.go
Normal 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
|
||||
}
|
||||
188
internal/service/registerreward/status.go
Normal file
188
internal/service/registerreward/status.go
Normal 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")
|
||||
}
|
||||
83
internal/service/registerreward/status_test.go
Normal file
83
internal/service/registerreward/status_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
112
internal/service/registerreward/stream.go
Normal file
112
internal/service/registerreward/stream.go
Normal 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:
|
||||
}
|
||||
}
|
||||
184
internal/service/registerreward/types.go
Normal file
184
internal/service/registerreward/types.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
7
internal/service/weekstar/aliases.go
Normal file
7
internal/service/weekstar/aliases.go
Normal file
@ -0,0 +1,7 @@
|
||||
package weekstar
|
||||
|
||||
import "chatapp3-golang/internal/common"
|
||||
|
||||
type AppError = common.AppError
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
533
internal/service/weekstar/config.go
Normal file
533
internal/service/weekstar/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
95
internal/service/weekstar/cycle.go
Normal file
95
internal/service/weekstar/cycle.go
Normal 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))
|
||||
}
|
||||
108
internal/service/weekstar/event.go
Normal file
108
internal/service/weekstar/event.go
Normal 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)
|
||||
}
|
||||
103
internal/service/weekstar/lifecycle.go
Normal file
103
internal/service/weekstar/lifecycle.go
Normal 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
|
||||
}
|
||||
232
internal/service/weekstar/mapper.go
Normal file
232
internal/service/weekstar/mapper.go
Normal 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
|
||||
}
|
||||
368
internal/service/weekstar/page.go
Normal file
368
internal/service/weekstar/page.go
Normal 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
|
||||
}
|
||||
48
internal/service/weekstar/retry_task.go
Normal file
48
internal/service/weekstar/retry_task.go
Normal 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)
|
||||
}
|
||||
277
internal/service/weekstar/types.go
Normal file
277
internal/service/weekstar/types.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package util
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
12
internal/utils/json.go
Normal file
12
internal/utils/json.go
Normal 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)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
104
migrations/003_week_star_activity.sql
Normal file
104
migrations/003_week_star_activity.sql
Normal 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;
|
||||
43
migrations/004_lucky_gift.sql
Normal file
43
migrations/004_lucky_gift.sql
Normal 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;
|
||||
100
migrations/005_register_reward_daily_limit.sql
Normal file
100
migrations/005_register_reward_daily_limit.sql
Normal 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;
|
||||
92
scripts/lucky_gift_java_go_live_smoke.example.json
Normal file
92
scripts/lucky_gift_java_go_live_smoke.example.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
scripts/lucky_gift_live_smoke.example.json
Normal file
27
scripts/lucky_gift_live_smoke.example.json
Normal 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
8
scripts/run_lucky_gift_e2e.sh
Executable 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 "$@"
|
||||
8
scripts/run_lucky_gift_java_go_live_smoke.sh
Executable file
8
scripts/run_lucky_gift_java_go_live_smoke.sh
Executable 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}" "$@"
|
||||
8
scripts/run_lucky_gift_live_smoke.sh
Executable file
8
scripts/run_lucky_gift_live_smoke.sh
Executable 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 "$@"
|
||||
Loading…
x
Reference in New Issue
Block a user