diff --git a/cmd/api/main.go b/cmd/api/main.go index 1df1105..5e55700 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -28,11 +28,13 @@ import ( "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/weekstar" + "chatapp3-golang/internal/service/wheel" ) func main() { @@ -57,6 +59,8 @@ func main() { signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) + wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways) + smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways) regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) @@ -111,8 +115,10 @@ func main() { RegisterReward: registerRewardService, RoomTurnoverReward: roomTurnoverRewardService, SignInReward: signInRewardService, + SmashEgg: smashEggService, TaskCenter: taskCenterService, VIP: vipService, + Wheel: wheelService, VoiceRoomRedPacket: voiceRoomRedPacketService, VoiceRoomRocket: voiceRoomRocketService, WeekStar: weekStarService, diff --git a/internal/model/smash_egg_models.go b/internal/model/smash_egg_models.go new file mode 100644 index 0000000..c924a9c --- /dev/null +++ b/internal/model/smash_egg_models.go @@ -0,0 +1,98 @@ +package model + +import "time" + +// SmashEggConfig stores the resident smash golden egg activity master config. +type SmashEggConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_smash_egg_config_sys_origin"` + Enabled bool `gorm:"column:enabled;index:idx_smash_egg_config_enabled"` + Timezone string `gorm:"column:timezone;size:64"` + RTPBasisPoints int `gorm:"column:rtp_basis_points"` + NewbiePoolEnabled bool `gorm:"column:newbie_pool_enabled"` + NewbieWindowDays int `gorm:"column:newbie_window_days"` + NewbieMaxDrawCount int `gorm:"column:newbie_max_draw_count"` + NewbieMinRechargeAmount int64 `gorm:"column:newbie_min_recharge_amount"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the smash egg master configuration table name. +func (SmashEggConfig) TableName() string { return "smash_egg_config" } + +// SmashEggDrawOptionConfig stores one draw button option. +type SmashEggDrawOptionConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_option_config,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_option_enabled,priority:1"` + OptionKey string `gorm:"column:option_key;size:32"` + Label string `gorm:"column:label;size:64"` + Times int `gorm:"column:times"` + PriceGold int64 `gorm:"column:price_gold"` + Sort int `gorm:"column:sort"` + Enabled bool `gorm:"column:enabled;index:idx_smash_egg_option_enabled,priority:2"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the smash egg draw option configuration table name. +func (SmashEggDrawOptionConfig) TableName() string { return "smash_egg_draw_option_config" } + +// SmashEggRewardConfig stores one prize on the smash egg activity. +type SmashEggRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_reward_config,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_reward_enabled,priority:1"` + PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_reward_pool,priority:2"` + RewardType string `gorm:"column:reward_type;size:32"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + RewardGroupName string `gorm:"column:reward_group_name;size:255"` + ResourceType string `gorm:"column:resource_type;size:64"` + ResourceURL string `gorm:"column:resource_url;size:512"` + CoverURL string `gorm:"column:cover_url;size:512"` + AnimationURL string `gorm:"column:animation_url;size:512"` + DurationDays int `gorm:"column:duration_days"` + DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardValueGold int64 `gorm:"column:reward_value_gold"` + Probability int `gorm:"column:probability"` + Sort int `gorm:"column:sort"` + Enabled bool `gorm:"column:enabled;index:idx_smash_egg_reward_enabled,priority:2"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the smash egg reward configuration table name. +func (SmashEggRewardConfig) TableName() string { return "smash_egg_reward_config" } + +// SmashEggDrawRecord stores each prize item produced by a smash egg draw. +type SmashEggDrawRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + DrawNo string `gorm:"column:draw_no;size:64;index:idx_smash_egg_draw_record_draw_no"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_smash_egg_draw_record_event"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_draw_record_user_time,priority:1;index:idx_smash_egg_draw_record_day,priority:1"` + UserID int64 `gorm:"column:user_id;index:idx_smash_egg_draw_record_user_time,priority:2"` + PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_draw_record_user_pool,priority:3"` + DrawOptionID int64 `gorm:"column:draw_option_id"` + DrawTimes int `gorm:"column:draw_times"` + PaidGold int64 `gorm:"column:paid_gold"` + RewardType string `gorm:"column:reward_type;size:32"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + RewardGroupName string `gorm:"column:reward_group_name;size:255"` + ResourceType string `gorm:"column:resource_type;size:64"` + ResourceURL string `gorm:"column:resource_url;size:512"` + CoverURL string `gorm:"column:cover_url;size:512"` + AnimationURL string `gorm:"column:animation_url;size:512"` + DurationDays int `gorm:"column:duration_days"` + DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardValueGold int64 `gorm:"column:reward_value_gold"` + Probability int `gorm:"column:probability"` + Status string `gorm:"column:status;size:32;index:idx_smash_egg_draw_record_status"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + CreateTime time.Time `gorm:"column:create_time;index:idx_smash_egg_draw_record_user_time,priority:3;index:idx_smash_egg_draw_record_day,priority:2"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the smash egg draw record table name. +func (SmashEggDrawRecord) TableName() string { return "smash_egg_draw_record" } diff --git a/internal/model/wheel_models.go b/internal/model/wheel_models.go new file mode 100644 index 0000000..f1fcab9 --- /dev/null +++ b/internal/model/wheel_models.go @@ -0,0 +1,88 @@ +package model + +import "time" + +// WheelConfig stores the resident activity wheel master configuration. +type WheelConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_wheel_config_sys_origin"` + Enabled bool `gorm:"column:enabled"` + Timezone string `gorm:"column:timezone;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the wheel master configuration table name. +func (WheelConfig) TableName() string { return "wheel_config" } + +// WheelPoolConfig stores category-level wheel prices and switches. +type WheelPoolConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_wheel_pool_config,priority:1;index:idx_wheel_pool_config,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_pool_sys_origin,priority:1"` + Category string `gorm:"column:category;size:32;uniqueIndex:uk_wheel_pool_config,priority:2;index:idx_wheel_pool_sys_origin,priority:2"` + Enabled bool `gorm:"column:enabled"` + PriceOneGold int64 `gorm:"column:price_one_gold"` + PriceTenGold int64 `gorm:"column:price_ten_gold"` + PriceFiftyGold int64 `gorm:"column:price_fifty_gold"` + Sort int `gorm:"column:sort"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the wheel pool configuration table name. +func (WheelPoolConfig) TableName() string { return "wheel_pool_config" } + +// WheelRewardConfig stores one reward option on the wheel. +type WheelRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_wheel_reward_config,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_reward_enabled,priority:1"` + Category string `gorm:"column:category;size:32;index:idx_wheel_reward_config,priority:2;index:idx_wheel_reward_enabled,priority:2"` + RewardType string `gorm:"column:reward_type;size:32"` + ResourceID *int64 `gorm:"column:resource_id"` + ResourceType string `gorm:"column:resource_type;size:64"` + ResourceName string `gorm:"column:resource_name;size:255"` + ResourceURL string `gorm:"column:resource_url;size:512"` + CoverURL string `gorm:"column:cover_url;size:512"` + DurationDays int `gorm:"column:duration_days"` + DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` + GoldAmount int64 `gorm:"column:gold_amount"` + Probability int `gorm:"column:probability"` + Sort int `gorm:"column:sort"` + Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the wheel reward configuration table name. +func (WheelRewardConfig) TableName() string { return "wheel_reward_config" } + +// WheelDrawRecord stores each reward item produced by a wheel draw. +type WheelDrawRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + DrawNo string `gorm:"column:draw_no;size:64;index:idx_wheel_draw_record_draw_no"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_wheel_draw_record_event"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_draw_record_user_time,priority:1"` + Category string `gorm:"column:category;size:32;index:idx_wheel_draw_record_category"` + UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"` + DrawTimes int `gorm:"column:draw_times"` + PaidGold int64 `gorm:"column:paid_gold"` + RewardType string `gorm:"column:reward_type;size:32"` + ResourceID *int64 `gorm:"column:resource_id"` + ResourceType string `gorm:"column:resource_type;size:64"` + ResourceName string `gorm:"column:resource_name;size:255"` + ResourceURL string `gorm:"column:resource_url;size:512"` + CoverURL string `gorm:"column:cover_url;size:512"` + DurationDays int `gorm:"column:duration_days"` + DisplayGoldAmount int64 `gorm:"column:display_gold_amount"` + GoldAmount int64 `gorm:"column:gold_amount"` + Probability int `gorm:"column:probability"` + Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the wheel draw record table name. +func (WheelDrawRecord) TableName() string { return "wheel_draw_record" } diff --git a/internal/router/router.go b/internal/router/router.go index 3e5476f..4b0d80c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,11 +22,13 @@ import ( "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/weekstar" + "chatapp3-golang/internal/service/wheel" "github.com/gin-gonic/gin" ) @@ -49,8 +51,10 @@ type Services struct { RegisterReward *registerreward.RegisterRewardService RoomTurnoverReward *roomturnoverreward.Service SignInReward *signinreward.SignInRewardService + SmashEgg *smashegg.Service TaskCenter *taskcenter.Service VIP *vip.Service + Wheel *wheel.Service VoiceRoomRedPacket *voiceroomredpacket.Service VoiceRoomRocket *voiceroomrocket.Service WeekStar *weekstar.WeekStarService @@ -76,6 +80,8 @@ func NewRouter( registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerVipRoutes(engine, javaClient, services.VIP) + registerWheelRoutes(engine, javaClient, services.Wheel) + registerSmashEggRoutes(engine, javaClient, services.SmashEgg) registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup) registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket) @@ -101,7 +107,7 @@ func corsMiddleware() gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") - c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-zone,X-Requested-With") + c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,sign,timestamp,token,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-version,req-zone,X-Requested-With") c.Header("Access-Control-Expose-Headers", "Authorization") c.Header("Access-Control-Max-Age", "86400") diff --git a/internal/router/smash_egg_routes.go b/internal/router/smash_egg_routes.go new file mode 100644 index 0000000..27f841f --- /dev/null +++ b/internal/router/smash_egg_routes.go @@ -0,0 +1,243 @@ +package router + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/service/smashegg" + + "github.com/gin-gonic/gin" +) + +// registerSmashEggRoutes registers app, legacy H5, and admin smash egg APIs. +func registerSmashEggRoutes(engine *gin.Engine, javaClient authGateway, service *smashegg.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/smash-golden-egg") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetAppConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/draw", func(c *gin.Context) { + var req smashegg.DrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageUserRecords(c.Request.Context(), mustAuthUser(c), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/notices", func(c *gin.Context) { + resp, err := service.ListRecentNotices(c.Request.Context(), mustAuthUser(c).SysOrigin, 20) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/rank/day", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100"))) + resp, err := service.PageDayRank(c.Request.Context(), mustAuthUser(c).SysOrigin, cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/resident-activity/smash-golden-egg") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/config/save", func(c *gin.Context) { + var req smashegg.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/draw-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 := service.PageAdminRecords( + c.Request.Context(), + c.Query("sysOrigin"), + smashegg.ParseUserID(c.Query("userId")), + c.Query("status"), + cursor, + limit, + c.Query("startTime"), + c.Query("endTime"), + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + h5Group := engine.Group("/h5/smash-golden-egg") + h5Group.Any("/config", func(c *gin.Context) { + handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getPrizeList") + }) + h5Group.Any("/draw", func(c *gin.Context) { + handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.startHunt") + }) + h5Group.Any("/records", func(c *gin.Context) { + handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getMyHuntRecord") + }) + h5Group.Any("/notices", func(c *gin.Context) { + handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getHuntRecord") + }) + h5Group.Any("/rank/day", func(c *gin.Context) { + handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getDayRank") + }) + + engine.Any("/index.php", func(c *gin.Context) { + action := strings.TrimSpace(c.Query("action")) + if !strings.HasPrefix(action, "Action/TreasureHunt.") { + writeError(c, smashegg.NewAppError(http.StatusNotFound, "not_found", "legacy action is not supported")) + return + } + handleLegacySmashEggAction(c, javaClient, service, action) + }) +} + +func handleLegacySmashEggAction(c *gin.Context, javaClient authGateway, service *smashegg.Service, action string) { + user, err := legacySmashEggUser(c, javaClient) + if err != nil { + writeLegacyError(c, http.StatusUnauthorized, err.Error()) + return + } + + var resp map[string]any + switch action { + case "Action/TreasureHunt.getPrizeList": + resp, err = service.LegacyPrizeList(c.Request.Context(), user) + case "Action/TreasureHunt.startHunt": + times, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "times", "num"))) + if times <= 0 { + times = 1 + } + resp, err = service.LegacyStartHunt(c.Request.Context(), user, times) + case "Action/TreasureHunt.getMyHuntRecord": + start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start"))) + limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num"))) + if limit <= 0 { + limit = 20 + } + resp, err = service.LegacyMyHuntRecords(c.Request.Context(), user, start, limit) + case "Action/TreasureHunt.getHuntRecord": + resp, err = service.LegacyHuntNotices(c.Request.Context(), user.SysOrigin) + case "Action/TreasureHunt.getDayRank": + resp, err = service.LegacyDayRank(c.Request.Context(), user.SysOrigin) + default: + writeLegacyError(c, http.StatusNotFound, "legacy action is not supported") + return + } + if err != nil { + writeLegacyError(c, http.StatusBadRequest, err.Error()) + return + } + writeLegacyOK(c, resp) +} + +func legacySmashEggUser(c *gin.Context, javaClient authGateway) (common.AuthUser, error) { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + token = trimBearer(strings.TrimSpace(c.GetHeader("Authorization"))) + } + if token != "" && javaClient != nil { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + credential, err := javaClient.AuthenticateToken(ctx, token) + if err != nil { + return common.AuthUser{}, err + } + return common.AuthUser{ + UserID: int64(credential.UserID), + SysOrigin: credential.SysOrigin, + Token: token, + }, nil + } + + userID := smashegg.ParseUserID(firstNonEmptyQuery(c, "_login_uid", "uid", "userId")) + sysOrigin := strings.ToUpper(strings.TrimSpace(firstNonEmptyQuery(c, "sysOrigin", "originSys"))) + if sysOrigin == "" { + sysOrigin = strings.ToUpper(strings.TrimSpace(c.GetHeader("req-sys-origin"))) + } + if userID <= 0 || sysOrigin == "" { + return common.AuthUser{}, common.NewAppError(http.StatusUnauthorized, "missing_token", "token is required") + } + return common.AuthUser{UserID: userID, SysOrigin: sysOrigin, Token: token}, nil +} + +func firstNonEmptyQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} + +func writeLegacyOK(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "response_data": data, + "response_status": gin.H{ + "code": 0, + "error": "", + }, + }) +} + +func writeLegacyError(c *gin.Context, code int, message string) { + if message == "" { + message = "error" + } + c.JSON(http.StatusOK, gin.H{ + "response_data": gin.H{}, + "response_status": gin.H{ + "code": code, + "error": message, + }, + }) +} diff --git a/internal/router/wheel_routes.go b/internal/router/wheel_routes.go new file mode 100644 index 0000000..7809eaf --- /dev/null +++ b/internal/router/wheel_routes.go @@ -0,0 +1,389 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/wheel" + + "github.com/gin-gonic/gin" +) + +const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png" + +// registerWheelRoutes registers app, admin, and copied H5-compatible wheel APIs. +func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) { + if wheelService == nil { + return + } + + appGroup := engine.Group("/app/wheel") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/config", func(c *gin.Context) { + resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/draw", func(c *gin.Context) { + var req wheel.DrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/history", func(c *gin.Context) { + cursor, limit := parseCursorLimit(c) + user := mustAuthUser(c) + resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, c.Query("category"), user.UserID, "SUCCESS", cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/hints", func(c *gin.Context) { + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), c.Query("category"), limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"values": resp}) + }) + + residentGroup := engine.Group("/resident-activity/wheel") + residentGroup.Use(consoleAuthMiddleware(javaClient)) + residentGroup.GET("/config", func(c *gin.Context) { + resp, err := wheelService.GetConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.POST("/config/save", func(c *gin.Context) { + var req wheel.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := wheelService.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.POST("/config/category/save", func(c *gin.Context) { + var req wheel.SaveCategoryConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := wheelService.SaveCategoryConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.GET("/record/page", func(c *gin.Context) { + cursor, limit := parseCursorLimit(c) + resp, err := wheelService.PageRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("category"), + wheel.ParseUserID(c.Query("userId")), + strings.TrimSpace(c.Query("status")), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + registerWheelLegacyH5Routes(engine, javaClient, wheelService) +} + +func registerWheelLegacyH5Routes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) { + legacyGroup := engine.Group("/client/api/v1") + legacyGroup.Use(authMiddleware(javaClient)) + legacyGroup.GET("/config/mobile", func(c *gin.Context) { + resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + legacyError(c, err) + return + } + classic := legacyCategoryConfig(resp, "CLASSIC") + luxury := legacyCategoryConfig(resp, "LUXURY") + advanced := legacyCategoryConfig(resp, "ADVANCED") + legacyOK(c, gin.H{ + "luckyBoxEnabled": classic.Enabled, + "middleLuckyBoxEnabled": luxury.Enabled, + "advancedLuckyBoxEnabled": advanced.Enabled, + "luckyBoxDrawAmount": classic.PriceOneGold, + "middleLuckyBoxDrawAmount": luxury.PriceOneGold, + "advancedLuckyBoxDrawAmount": advanced.PriceOneGold, + "luckyBoxDraw10Amount": classic.PriceTenGold, + "middleLuckyBoxDraw10Amount": luxury.PriceTenGold, + "advancedLuckyBoxDraw10Amount": advanced.PriceTenGold, + "luckyBoxDraw50Amount": classic.PriceFiftyGold, + "middleLuckyBoxDraw50Amount": luxury.PriceFiftyGold, + "advancedLuckyBoxDraw50Amount": advanced.PriceFiftyGold, + "wheelDrawAmount": classic.PriceOneGold, + "wheelDraw10Amount": classic.PriceTenGold, + "wheelDraw50Amount": classic.PriceFiftyGold, + }) + }) + legacyGroup.POST("/wallet/list", func(c *gin.Context) { + balance, err := wheelService.WalletBalances(c.Request.Context(), mustAuthUser(c)) + if err != nil { + legacyError(c, err) + return + } + legacyOK(c, []gin.H{ + {"walletType": "COIN", "balance": balance}, + {"walletType": "DCOIN", "balance": 0}, + }) + }) + + probabilityGroup := legacyGroup.Group("/probability") + for _, prefix := range []string{"lucky-box", "middle-lucky-box", "advanced-lucky-box"} { + pathPrefix := "/" + prefix + category := legacyCategoryFromPrefix(prefix) + probabilityGroup.GET(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category)) + probabilityGroup.POST(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category)) + probabilityGroup.GET(pathPrefix+"/history", legacyHistoryHandler(wheelService, category)) + probabilityGroup.POST(pathPrefix+"/history", legacyHistoryHandler(wheelService, category)) + probabilityGroup.GET(pathPrefix+"/hints", legacyHintsHandler(wheelService, category)) + probabilityGroup.POST(pathPrefix+"/hints", legacyHintsHandler(wheelService, category)) + probabilityGroup.POST(pathPrefix+"/draw", legacyDrawHandler(wheelService, category)) + } +} + +func legacyRewardsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc { + return func(c *gin.Context) { + resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + legacyError(c, err) + return + } + pool := legacyCategoryConfig(resp, category) + if !pool.Enabled { + legacyOK(c, []gin.H{}) + return + } + items := make([]gin.H, 0, len(pool.Rewards)) + for _, reward := range pool.Rewards { + items = append(items, gin.H{ + "id": reward.ID, + "value": legacyRewardValue(reward), + "merchandise": gin.H{ + "name": legacyRewardName(reward), + "coverUrl": legacyRewardCover(reward), + }, + }) + } + legacyOK(c, items) + } +} + +func legacyDrawHandler(wheelService *wheel.Service, category string) gin.HandlerFunc { + return func(c *gin.Context) { + var req wheel.DrawRequest + _ = c.ShouldBindJSON(&req) + req.Category = category + resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + legacyError(c, err) + return + } + rewards := make([]gin.H, 0, len(resp.Rewards)) + for _, reward := range resp.Rewards { + rewards = append(rewards, gin.H{ + "coverUrl": legacyDrawRewardCover(reward), + "count": reward.Count, + "name": legacyDrawRewardName(reward), + "value": reward.Value, + }) + } + legacyOK(c, gin.H{ + "drawNo": resp.DrawNo, + "roomId": resp.RoomID, + "rewardValue": resp.RewardValue, + "rewards": rewards, + "balanceAfter": resp.BalanceAfter, + }) + } +} + +func legacyHistoryHandler(wheelService *wheel.Service, category string) gin.HandlerFunc { + return func(c *gin.Context) { + cursor, limit := parseCursorLimit(c) + user := mustAuthUser(c) + resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, category, user.UserID, "SUCCESS", cursor, limit) + if err != nil { + legacyError(c, err) + return + } + records := make([]gin.H, 0, len(resp.Records)) + for _, record := range resp.Records { + records = append(records, gin.H{ + "createdTime": record.CreateTime, + "drawNo": record.DrawNo, + "drawTimes": record.DrawTimes, + "rewardValue": legacyRecordValue(record), + "rewards": []gin.H{{ + "coverUrl": legacyRecordCover(record), + "count": 1, + "name": legacyRecordName(record), + "value": legacyRecordValue(record), + }}, + }) + } + legacyOK(c, gin.H{"records": records, "total": resp.Total}) + } +} + +func legacyHintsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc { + return func(c *gin.Context) { + resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), category, 20) + if err != nil { + legacyError(c, err) + return + } + values := make([]gin.H, 0, len(resp)) + for index, reward := range resp { + values = append(values, gin.H{ + "avatar": reward.CoverURL, + "nickname": "User" + strconv.Itoa(index+1), + "value": legacyDrawRewardName(reward), + }) + } + legacyOK(c, gin.H{"values": values}) + } +} + +func parseCursorLimit(c *gin.Context) (int, int) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + return cursor, limit +} + +func legacyOK(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": data}) +} + +func legacyError(c *gin.Context, err error) { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "data": nil}) +} + +func legacyCategoryFromPrefix(prefix string) string { + switch prefix { + case "middle-lucky-box": + return "LUXURY" + case "advanced-lucky-box": + return "ADVANCED" + default: + return "CLASSIC" + } +} + +func legacyCategoryConfig(resp *wheel.ConfigResponse, category string) wheel.CategoryConfigPayload { + for _, item := range resp.Categories { + if strings.EqualFold(item.Category, category) { + return item + } + } + return wheel.CategoryConfigPayload{Category: category, Rewards: []wheel.RewardConfigPayload{}} +} + +func legacyRewardValue(reward wheel.RewardConfigPayload) int64 { + if strings.EqualFold(reward.RewardType, "GOLD") { + return reward.GoldAmount + } + return reward.DisplayGoldAmount +} + +func legacyRewardCover(reward wheel.RewardConfigPayload) string { + if strings.TrimSpace(reward.CoverURL) != "" { + return reward.CoverURL + } + if strings.EqualFold(reward.RewardType, "GOLD") { + return legacyGoldRewardCoverURL + } + return "" +} + +func legacyDrawRewardCover(reward wheel.DrawRewardPayload) string { + if strings.TrimSpace(reward.CoverURL) != "" { + return reward.CoverURL + } + if strings.EqualFold(reward.RewardType, "GOLD") { + return legacyGoldRewardCoverURL + } + return "" +} + +func legacyRecordCover(record wheel.DrawRecordPayload) string { + if strings.TrimSpace(record.CoverURL) != "" { + return record.CoverURL + } + if strings.EqualFold(record.RewardType, "GOLD") { + return legacyGoldRewardCoverURL + } + return "" +} + +func legacyRecordValue(record wheel.DrawRecordPayload) int64 { + if strings.EqualFold(record.RewardType, "GOLD") { + return record.GoldAmount + } + return record.DisplayGoldAmount +} + +func legacyRewardName(reward wheel.RewardConfigPayload) string { + if reward.ResourceName != "" { + return reward.ResourceName + } + if reward.RewardType == "GOLD" { + return "金币" + } + return "Reward" +} + +func legacyDrawRewardName(reward wheel.DrawRewardPayload) string { + if reward.Name != "" { + return reward.Name + } + if reward.ResourceName != "" { + return reward.ResourceName + } + if reward.RewardType == "GOLD" { + return "金币" + } + return "Reward" +} + +func legacyRecordName(record wheel.DrawRecordPayload) string { + if record.ResourceName != "" { + return record.ResourceName + } + if record.RewardType == "GOLD" { + return "金币" + } + return "Reward" +} diff --git a/internal/service/smashegg/config.go b/internal/service/smashegg/config.go new file mode 100644 index 0000000..caf9cc0 --- /dev/null +++ b/internal/service/smashegg/config.go @@ -0,0 +1,624 @@ +package smashegg + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +type configBundle struct { + Config *model.SmashEggConfig + DrawOptions []model.SmashEggDrawOptionConfig + Rewards []model.SmashEggRewardConfig +} + +// GetConfig returns admin smash egg configuration. +func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { + bundle, err := s.loadConfigBundle(ctx, sysOrigin, false) + if err != nil { + return nil, err + } + if bundle.Config == nil { + return defaultConfigResponse(sysOrigin), nil + } + return s.buildConfigResponse(ctx, bundle, true) +} + +// GetAppConfig returns enabled smash egg configuration for app clients. +func (s *Service) GetAppConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { + bundle, err := s.loadConfigBundle(ctx, user.SysOrigin, true) + if err != nil { + return nil, err + } + if bundle.Config == nil || !bundle.Config.Enabled { + return &ConfigResponse{ + Configured: false, + SysOrigin: normalizeSysOrigin(user.SysOrigin), + Timezone: defaultTimezone, + RTPBasisPoints: defaultRTPBasisPoints, + RTPPercent: basisPointsPercent(defaultRTPBasisPoints), + ProbabilityTotal: probabilityTotal, + NewbieWindowDays: defaultNewbieWindowDays, + NewbieMaxDrawCount: defaultNewbieMaxDrawCount, + ActivePoolType: poolTypeGeneral, + DrawOptions: []DrawOptionPayload{}, + Rewards: []RewardConfigPayload{}, + }, nil + } + resp, err := s.buildConfigResponse(ctx, bundle, false) + if err != nil { + return nil, err + } + poolType, rewards, eligibility, err := s.resolveRewardPool(ctx, bundle, user) + if err != nil { + return nil, err + } + options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions)) + for _, row := range bundle.DrawOptions { + expectedValue := expectedRewardValueForOption(bundle.Config.RTPBasisPoints, row, rewards) + options = append(options, drawOptionPayload(row, expectedValue)) + } + resp.ActivePoolType = poolType + resp.NewbieEligible = eligibility.Eligible + resp.NewbieRemainingDrawCount = eligibility.Remaining + resp.DrawOptions = options + resp.Rewards = rewardPayloadsFromConfig(rewards) + resp.ExpectedValueGold = amount2(expectedRewardValue(rewards)) + resp.NewbieRewards = nil + return resp, nil +} + +// SaveConfig saves admin smash egg configuration. +func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + normalized, err := normalizeSaveRequest(req) + if err != nil { + return nil, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var configRow model.SmashEggConfig + if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.SmashEggConfig{ + ID: nextID, + SysOrigin: normalized.SysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + + configRow.Enabled = normalized.Enabled + configRow.Timezone = normalized.Timezone + configRow.RTPBasisPoints = normalized.RTPBasisPoints + configRow.NewbiePoolEnabled = normalized.NewbiePoolEnabled + configRow.NewbieWindowDays = normalized.NewbieWindowDays + configRow.NewbieMaxDrawCount = normalized.NewbieMaxDrawCount + configRow.NewbieMinRechargeAmount = normalized.NewbieMinRechargeAmount + configRow.UpdateTime = now + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + if err := tx.Save(&configRow).Error; err != nil { + return err + } + + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggDrawOptionConfig{}).Error; err != nil { + return err + } + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggRewardConfig{}).Error; err != nil { + return err + } + + optionRows := make([]model.SmashEggDrawOptionConfig, 0, len(normalized.DrawOptions)) + for index, item := range normalized.DrawOptions { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + sortValue := item.Sort + if sortValue <= 0 { + sortValue = index + 1 + } + optionRows = append(optionRows, model.SmashEggDrawOptionConfig{ + ID: nextID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + OptionKey: item.OptionKey, + Label: strings.TrimSpace(item.Label), + Times: item.Times, + PriceGold: item.PriceGold, + Sort: sortValue, + Enabled: item.Enabled, + CreateTime: now, + UpdateTime: now, + }) + } + if len(optionRows) > 0 { + if err := tx.Create(&optionRows).Error; err != nil { + return err + } + } + + allRewards := make([]RewardConfigInput, 0, len(normalized.Rewards)+len(normalized.NewbieRewards)) + allRewards = append(allRewards, normalized.Rewards...) + allRewards = append(allRewards, normalized.NewbieRewards...) + rewardRows := make([]model.SmashEggRewardConfig, 0, len(allRewards)) + for index, item := range allRewards { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + sortValue := item.Sort + if sortValue <= 0 { + sortValue = index + 1 + } + rewardRows = append(rewardRows, model.SmashEggRewardConfig{ + ID: nextID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + PoolType: normalizePoolType(item.PoolType), + RewardType: item.RewardType, + RewardGroupID: item.RewardGroupID, + RewardGroupName: strings.TrimSpace(item.RewardGroupName), + ResourceType: strings.TrimSpace(item.ResourceType), + ResourceURL: strings.TrimSpace(item.ResourceURL), + CoverURL: strings.TrimSpace(item.CoverURL), + AnimationURL: strings.TrimSpace(item.AnimationURL), + DurationDays: item.DurationDays, + DisplayGoldAmount: item.DisplayGoldAmount, + GoldAmount: item.GoldAmount, + RewardValueGold: item.RewardValueGold, + Probability: item.Probability, + Sort: sortValue, + Enabled: item.Enabled, + CreateTime: now, + UpdateTime: now, + }) + } + if len(rewardRows) > 0 { + return tx.Create(&rewardRows).Error + } + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, normalized.SysOrigin) +} + +func normalizeSaveRequest(req SaveConfigRequest) (SaveConfigRequest, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + timezone := normalizeTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + rtp := req.RTPBasisPoints + if rtp <= 0 { + rtp = defaultRTPBasisPoints + } + if rtp > 100000 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_rtp", "rtpBasisPoints must be less than or equal to 100000") + } + + options := append([]DrawOptionInput(nil), req.DrawOptions...) + if len(options) == 0 { + options = defaultDrawOptionInputs() + } + sortOptionInputs(options) + enabledOptionCount := 0 + seenTimes := map[int]struct{}{} + for index := range options { + item := &options[index] + item.OptionKey = normalizeOptionKey(item.OptionKey, index) + item.Label = strings.TrimSpace(item.Label) + if item.Label == "" { + item.Label = item.OptionKey + } + if item.Sort <= 0 { + item.Sort = index + 1 + } + if item.Times <= 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option times must be greater than 0") + } + if item.PriceGold <= 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_price", "draw option priceGold must be greater than 0") + } + if _, exists := seenTimes[item.Times]; exists { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "duplicate_draw_times", "draw option times must be unique") + } + seenTimes[item.Times] = struct{}{} + if item.Enabled { + enabledOptionCount++ + } + } + + rewards, enabledRewardCount, err := normalizeRewardInputs(req.Rewards, poolTypeGeneral) + if err != nil { + return SaveConfigRequest{}, err + } + newbieRewards, newbieEnabledRewardCount, err := normalizeRewardInputs(req.NewbieRewards, poolTypeNewbie) + if err != nil { + return SaveConfigRequest{}, err + } + + newbieWindowDays := req.NewbieWindowDays + if newbieWindowDays <= 0 { + newbieWindowDays = defaultNewbieWindowDays + } + if newbieWindowDays > 90 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_window_days", "newbieWindowDays must be less than or equal to 90") + } + newbieMaxDrawCount := req.NewbieMaxDrawCount + if req.NewbiePoolEnabled && newbieMaxDrawCount <= 0 { + newbieMaxDrawCount = defaultNewbieMaxDrawCount + } + if newbieMaxDrawCount < 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_max_draw_count", "newbieMaxDrawCount must be greater than or equal to 0") + } + if req.NewbieMinRechargeAmount < 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_min_recharge_amount", "newbieMinRechargeAmount must be greater than or equal to 0") + } + if req.Enabled && enabledOptionCount == 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_draw_options", "enabled smash egg requires at least one enabled draw option") + } + if req.Enabled && enabledRewardCount == 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_rewards", "enabled smash egg requires at least one enabled reward") + } + if enabledOptionCount > 0 && enabledRewardCount > 0 { + rewards, err = applyAutoProbabilitiesToInputs(rtp, options, rewards, "general") + if err != nil { + return SaveConfigRequest{}, err + } + } + if req.Enabled && req.NewbiePoolEnabled && newbieEnabledRewardCount == 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_newbie_rewards", "enabled newbie pool requires at least one enabled reward") + } + if req.NewbiePoolEnabled && enabledOptionCount > 0 && newbieEnabledRewardCount > 0 { + newbieRewards, err = applyAutoProbabilitiesToInputs(rtp, options, newbieRewards, "newbie") + if err != nil { + return SaveConfigRequest{}, err + } + } + + return SaveConfigRequest{ + SysOrigin: sysOrigin, + Enabled: req.Enabled, + Timezone: timezone, + RTPBasisPoints: rtp, + NewbiePoolEnabled: req.NewbiePoolEnabled, + NewbieWindowDays: newbieWindowDays, + NewbieMaxDrawCount: newbieMaxDrawCount, + NewbieMinRechargeAmount: req.NewbieMinRechargeAmount, + DrawOptions: options, + Rewards: rewards, + NewbieRewards: newbieRewards, + }, nil +} + +func normalizeRewardInputs(input []RewardConfigInput, poolType string) ([]RewardConfigInput, int, error) { + rewards := append([]RewardConfigInput(nil), input...) + sortRewardInputs(rewards) + enabledRewardCount := 0 + for index := range rewards { + item := &rewards[index] + item.PoolType = normalizePoolType(poolType) + item.RewardType = normalizeRewardType(item.RewardType) + if item.RewardType == "" { + item.RewardType = rewardTypeGold + } + item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType)) + item.ResourceName = strings.TrimSpace(firstNonEmpty(item.ResourceName, item.RewardGroupName)) + item.ResourceURL = strings.TrimSpace(item.ResourceURL) + item.RewardGroupName = strings.TrimSpace(item.RewardGroupName) + item.CoverURL = strings.TrimSpace(item.CoverURL) + item.AnimationURL = strings.TrimSpace(item.AnimationURL) + if item.Sort <= 0 { + item.Sort = index + 1 + } + + switch item.RewardType { + case rewardTypeGold: + if item.GoldAmount <= 0 { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_gold_amount", "gold reward requires goldAmount") + } + item.ResourceID = 0 + item.ResourceType = "" + item.ResourceName = "" + item.ResourceURL = "" + item.RewardGroupID = nil + item.RewardGroupName = "金币" + item.DurationDays = 0 + item.DisplayGoldAmount = 0 + item.RewardValueGold = item.GoldAmount + case rewardTypeResource: + if item.ResourceID.Int64() <= 0 && item.RewardGroupID != nil { + item.ResourceID = ResourceID(*item.RewardGroupID) + } + if item.ResourceID.Int64() <= 0 { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource", "resource reward requires resourceId") + } + if item.ResourceType == "" { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource_type", "resource reward requires resourceType") + } + if item.DurationDays <= 0 { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_duration_days", "resource reward requires durationDays greater than 0") + } + if item.DisplayGoldAmount <= 0 { + if item.RewardValueGold > 0 { + item.DisplayGoldAmount = item.RewardValueGold + } else { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", "resource reward displayGoldAmount must be greater than 0") + } + } + item.RewardGroupID = resourceIDPtr(item.ResourceID) + if item.ResourceName == "" { + item.ResourceName = "资源 " + strconv.FormatInt(item.ResourceID.Int64(), 10) + } + item.RewardGroupName = item.ResourceName + item.GoldAmount = 0 + item.RewardValueGold = item.DisplayGoldAmount + if item.AnimationURL == "" { + item.AnimationURL = item.ResourceURL + } + default: + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD") + } + + if item.RewardValueGold <= 0 { + return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_value", "reward value must be greater than 0") + } + item.Probability = 0 + if item.Enabled { + enabledRewardCount++ + } + } + return rewards, enabledRewardCount, nil +} + +func weightedRewardValueFromInputs(rewards []RewardConfigInput) int64 { + var total int64 + for _, reward := range rewards { + if !reward.Enabled || reward.Probability <= 0 { + continue + } + total += reward.RewardValueGold * int64(reward.Probability) + } + return total +} + +func drawOptionRTPBasisPointsFromWeightedValue(weightedValue int64, times int, priceGold int64) int { + if weightedValue <= 0 || times <= 0 || priceGold <= 0 { + return 0 + } + numerator := weightedValue * int64(times) * 10000 + denominator := priceGold * int64(probabilityTotal) + return int(roundDiv(numerator, denominator)) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func effectiveNewbieWindowDays(value int) int { + if value <= 0 { + return defaultNewbieWindowDays + } + return value +} + +func effectiveNewbieMaxDrawCount(value int, enabled bool) int { + if value <= 0 && enabled { + return defaultNewbieMaxDrawCount + } + return value +} + +func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabled bool) (*configBundle, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + var configRow model.SmashEggConfig + err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &configBundle{}, nil + } + if err != nil { + return nil, err + } + + optionQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc") + rewardQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc") + if onlyEnabled { + optionQuery = optionQuery.Where("enabled = ?", true) + rewardQuery = rewardQuery.Where("enabled = ?", true) + } + + var options []model.SmashEggDrawOptionConfig + if err := optionQuery.Find(&options).Error; err != nil { + return nil, err + } + var rewards []model.SmashEggRewardConfig + if err := rewardQuery.Find(&rewards).Error; err != nil { + return nil, err + } + return &configBundle{Config: &configRow, DrawOptions: options, Rewards: rewards}, nil +} + +func defaultConfigResponse(sysOrigin string) *ConfigResponse { + return &ConfigResponse{ + Configured: false, + SysOrigin: normalizeSysOrigin(sysOrigin), + Enabled: false, + Timezone: defaultTimezone, + RTPBasisPoints: defaultRTPBasisPoints, + RTPPercent: basisPointsPercent(defaultRTPBasisPoints), + ProbabilityTotal: probabilityTotal, + ExpectedValueGold: "0.00", + NewbieWindowDays: defaultNewbieWindowDays, + NewbieMaxDrawCount: defaultNewbieMaxDrawCount, + ActivePoolType: poolTypeGeneral, + DrawOptions: defaultDrawOptionPayloads(0), + Rewards: []RewardConfigPayload{}, + NewbieRewards: []RewardConfigPayload{}, + } +} + +func (s *Service) buildConfigResponse(ctx context.Context, bundle *configBundle, includeRewardItems bool) (*ConfigResponse, error) { + configRow := bundle.Config + generalRewards := rewardsForPool(bundle.Rewards, poolTypeGeneral) + newbieRewards := rewardsForPool(bundle.Rewards, poolTypeNewbie) + expectedValue := expectedRewardValue(generalRewards) + newbieExpectedValue := expectedRewardValue(newbieRewards) + + options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions)) + for _, row := range bundle.DrawOptions { + optionExpectedValue := expectedRewardValueForOption(configRow.RTPBasisPoints, row, generalRewards) + options = append(options, drawOptionPayload(row, optionExpectedValue)) + } + rewards := rewardPayloadsFromConfig(generalRewards) + newbieRewardPayloads := rewardPayloadsFromConfig(newbieRewards) + + return &ConfigResponse{ + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Timezone: normalizeTimezone(configRow.Timezone), + RTPBasisPoints: configRow.RTPBasisPoints, + RTPPercent: basisPointsPercent(configRow.RTPBasisPoints), + ProbabilityTotal: probabilityTotal, + ExpectedValueGold: amount2(expectedValue), + NewbiePoolEnabled: configRow.NewbiePoolEnabled, + NewbieWindowDays: effectiveNewbieWindowDays(configRow.NewbieWindowDays), + NewbieMaxDrawCount: effectiveNewbieMaxDrawCount(configRow.NewbieMaxDrawCount, configRow.NewbiePoolEnabled), + NewbieMinRechargeAmount: configRow.NewbieMinRechargeAmount, + NewbieExpectedValueGold: amount2(newbieExpectedValue), + ActivePoolType: poolTypeGeneral, + DrawOptions: options, + Rewards: rewards, + NewbieRewards: newbieRewardPayloads, + UpdateTime: formatDateTime(configRow.UpdateTime), + }, nil +} + +func rewardPayloadsFromConfig(rewards []model.SmashEggRewardConfig) []RewardConfigPayload { + payloads := make([]RewardConfigPayload, 0, len(rewards)) + for _, row := range rewards { + payloads = append(payloads, rewardPayloadFromConfig(row)) + } + return payloads +} + +func drawOptionPayload(row model.SmashEggDrawOptionConfig, expectedValuePerDraw float64) DrawOptionPayload { + rtp := 0 + if row.PriceGold > 0 { + rtp = int((expectedValuePerDraw * float64(row.Times) / float64(row.PriceGold) * 10000) + 0.5) + } + return DrawOptionPayload{ + ID: row.ID, + Enabled: row.Enabled, + Sort: row.Sort, + OptionKey: row.OptionKey, + Label: row.Label, + Times: row.Times, + PriceGold: row.PriceGold, + RTPBasisPoints: rtp, + RTPPercent: basisPointsPercent(rtp), + ExpectedValueGold: amount2(expectedValuePerDraw * float64(row.Times)), + } +} + +func rewardPayloadFromConfig(row model.SmashEggRewardConfig) RewardConfigPayload { + return RewardConfigPayload{ + ID: row.ID, + Enabled: row.Enabled, + Sort: row.Sort, + PoolType: normalizePoolType(row.PoolType), + RewardType: row.RewardType, + ResourceID: ResourceID(int64PtrValue(row.RewardGroupID)), + ResourceType: row.ResourceType, + ResourceName: row.RewardGroupName, + ResourceURL: row.ResourceURL, + RewardGroupID: row.RewardGroupID, + RewardGroupName: row.RewardGroupName, + CoverURL: row.CoverURL, + AnimationURL: row.AnimationURL, + DurationDays: row.DurationDays, + DisplayGoldAmount: row.DisplayGoldAmount, + GoldAmount: row.GoldAmount, + RewardValueGold: row.RewardValueGold, + Probability: row.Probability, + ProbabilityPercent: probabilityPercent(row.Probability), + } +} + +func rewardsForPool(rewards []model.SmashEggRewardConfig, poolType string) []model.SmashEggRewardConfig { + poolType = normalizePoolType(poolType) + result := make([]model.SmashEggRewardConfig, 0, len(rewards)) + for _, reward := range rewards { + reward.RewardType = normalizeRewardType(reward.RewardType) + if normalizePoolType(reward.PoolType) == poolType { + result = append(result, reward) + } + } + return result +} + +func expectedRewardValue(rewards []model.SmashEggRewardConfig) float64 { + var total float64 + for _, reward := range rewards { + if !reward.Enabled || reward.Probability <= 0 { + continue + } + total += float64(reward.RewardValueGold) * float64(reward.Probability) / probabilityTotal + } + return total +} + +func defaultDrawOptionInputs() []DrawOptionInput { + return []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 500}, + {Enabled: true, Sort: 2, OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 5000}, + {Enabled: true, Sort: 3, OptionKey: optionKeyHundred, Label: "Smash 100", Times: 100, PriceGold: 50000}, + } +} + +func defaultDrawOptionPayloads(expectedValuePerDraw float64) []DrawOptionPayload { + defaults := defaultDrawOptionInputs() + rows := make([]DrawOptionPayload, 0, len(defaults)) + for _, item := range defaults { + rows = append(rows, drawOptionPayload(model.SmashEggDrawOptionConfig{ + Enabled: item.Enabled, + Sort: item.Sort, + OptionKey: item.OptionKey, + Label: item.Label, + Times: item.Times, + PriceGold: item.PriceGold, + }, expectedValuePerDraw)) + } + return rows +} diff --git a/internal/service/smashegg/config_test.go b/internal/service/smashegg/config_test.go new file mode 100644 index 0000000..8282cac --- /dev/null +++ b/internal/service/smashegg/config_test.go @@ -0,0 +1,186 @@ +package smashegg + +import ( + "testing" + + "chatapp3-golang/internal/integration" +) + +func TestNormalizeSaveRequestAcceptsNewbiePoolWithinRTPTolerance(t *testing.T) { + normalized, err := normalizeSaveRequest(SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: true, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + NewbiePoolEnabled: true, + NewbieWindowDays: 7, + NewbieMaxDrawCount: 3, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100}, + {Enabled: true, Sort: 2, OptionKey: "TEN", Label: "10", Times: 10, PriceGold: 1000}, + }, + Rewards: []RewardConfigInput{{ + Enabled: true, + RewardType: rewardTypeGold, + GoldAmount: 95, + RewardValueGold: 95, + }}, + NewbieRewards: []RewardConfigInput{{ + Enabled: true, + RewardType: rewardTypeGold, + GoldAmount: 96, + RewardValueGold: 96, + }}, + }) + if err != nil { + t.Fatalf("normalizeSaveRequest returned error: %v", err) + } + if normalized.Rewards[0].Probability != probabilityTotal { + t.Fatalf("general probability = %d, want %d", normalized.Rewards[0].Probability, probabilityTotal) + } + if normalized.NewbieRewards[0].Probability != probabilityTotal { + t.Fatalf("newbie probability = %d, want %d", normalized.NewbieRewards[0].Probability, probabilityTotal) + } +} + +func TestNormalizeSaveRequestAutoCalculatesProbabilities(t *testing.T) { + normalized, err := normalizeSaveRequest(SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: true, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 500}, + }, + Rewards: []RewardConfigInput{ + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 50}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 100}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 500}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 5000}, + }, + }) + if err != nil { + t.Fatalf("normalizeSaveRequest returned error: %v", err) + } + total := 0 + for _, reward := range normalized.Rewards { + total += reward.Probability + } + if total != probabilityTotal { + t.Fatalf("probability total = %d, want %d", total, probabilityTotal) + } + weighted := weightedRewardValueFromInputs(normalized.Rewards) + rtp := drawOptionRTPBasisPointsFromWeightedValue(weighted, normalized.DrawOptions[0].Times, normalized.DrawOptions[0].PriceGold) + if rtp < normalized.RTPBasisPoints-rtpToleranceBasisPoints || rtp > normalized.RTPBasisPoints+rtpToleranceBasisPoints { + t.Fatalf("auto RTP = %d, want %d ± %d", rtp, normalized.RTPBasisPoints, rtpToleranceBasisPoints) + } +} + +func TestNormalizeSaveRequestAutoCalculatesDraftProbabilities(t *testing.T) { + normalized, err := normalizeSaveRequest(SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: false, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 500}, + }, + Rewards: []RewardConfigInput{ + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 50}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 100}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 500}, + {Enabled: true, RewardType: rewardTypeGold, GoldAmount: 10000}, + }, + }) + if err != nil { + t.Fatalf("normalizeSaveRequest returned error: %v", err) + } + total := 0 + for _, reward := range normalized.Rewards { + if reward.Enabled && reward.Probability <= 0 { + t.Fatalf("enabled reward %d probability = %d, want > 0", reward.GoldAmount, reward.Probability) + } + total += reward.Probability + } + if total != probabilityTotal { + t.Fatalf("probability total = %d, want %d", total, probabilityTotal) + } +} + +func TestNormalizeSaveRequestRejectsNewbiePoolOutsideRTPTolerance(t *testing.T) { + _, err := normalizeSaveRequest(SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: true, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + NewbiePoolEnabled: true, + NewbieWindowDays: 7, + NewbieMaxDrawCount: 3, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100}, + }, + Rewards: []RewardConfigInput{{ + Enabled: true, + RewardType: rewardTypeGold, + GoldAmount: 95, + RewardValueGold: 95, + }}, + NewbieRewards: []RewardConfigInput{{ + Enabled: true, + RewardType: rewardTypeGold, + GoldAmount: 110, + RewardValueGold: 110, + }}, + }) + if err == nil { + t.Fatal("normalizeSaveRequest succeeded, want RTP validation error") + } +} + +func TestNormalizeSaveRequestAcceptsResourceRewardAndUsesDisplayPrice(t *testing.T) { + normalized, err := normalizeSaveRequest(SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: true, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100}, + }, + Rewards: []RewardConfigInput{{ + Enabled: true, + RewardType: "RESOURCE", + ResourceID: ResourceID(123), + ResourceName: "Avatar Frame", + ResourceType: "AVATAR_FRAME", + CoverURL: "https://example.test/frame.png", + DurationDays: 7, + DisplayGoldAmount: 95, + }}, + }) + if err != nil { + t.Fatalf("normalizeSaveRequest returned error: %v", err) + } + reward := normalized.Rewards[0] + if reward.RewardType != rewardTypeResource { + t.Fatalf("reward type = %s, want RESOURCE", reward.RewardType) + } + if reward.RewardGroupID == nil || *reward.RewardGroupID != 123 { + t.Fatalf("reward group id = %v, want 123", reward.RewardGroupID) + } + if reward.RewardValueGold != 95 || reward.DisplayGoldAmount != 95 || reward.GoldAmount != 0 { + t.Fatalf("normalized resource values = value:%d display:%d gold:%d", reward.RewardValueGold, reward.DisplayGoldAmount, reward.GoldAmount) + } +} + +func TestTotalRechargeAtLeast(t *testing.T) { + items := []integration.UserTotalRecharge{ + {Amount: integration.DecimalString("80.50")}, + {Amount: integration.DecimalString("20")}, + } + if !totalRechargeAtLeast(items, 100) { + t.Fatal("totalRechargeAtLeast returned false, want true") + } + if totalRechargeAtLeast(items, 101) { + t.Fatal("totalRechargeAtLeast returned true, want false") + } +} diff --git a/internal/service/smashegg/draw.go b/internal/service/smashegg/draw.go new file mode 100644 index 0000000..4bd9100 --- /dev/null +++ b/internal/service/smashegg/draw.go @@ -0,0 +1,392 @@ +package smashegg + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" +) + +// Draw executes one smash egg draw request for an authenticated user. +func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResponse, error) { + sysOrigin := normalizeSysOrigin(user.SysOrigin) + if sysOrigin == "" || user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") + } + if s.java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "smash_egg_gateway_unavailable", "reward gateway is unavailable") + } + + bundle, err := s.loadConfigBundle(ctx, sysOrigin, true) + if err != nil { + return nil, err + } + if bundle.Config == nil || !bundle.Config.Enabled { + return nil, NewAppError(http.StatusNotFound, "smash_egg_not_available", "smash egg is not enabled") + } + if len(bundle.DrawOptions) == 0 { + return nil, NewAppError(http.StatusNotFound, "smash_egg_options_empty", "smash egg draw options are not configured") + } + if len(bundle.Rewards) == 0 { + return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") + } + + option, err := optionForTimes(bundle.DrawOptions, req.Times) + if err != nil { + return nil, err + } + poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user) + if err != nil { + return nil, err + } + if len(rewardPool) == 0 { + return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") + } + rewardPool, err = rewardsWithAutoProbabilitiesForOption(bundle.Config.RTPBasisPoints, option, rewardPool) + if err != nil { + return nil, err + } + selected := make([]model.SmashEggRewardConfig, 0, option.Times) + for index := 0; index < option.Times; index++ { + reward, pickErr := pickReward(rewardPool) + if pickErr != nil { + return nil, pickErr + } + selected = append(selected, reward) + } + + drawID, err := utils.NextID() + if err != nil { + return nil, err + } + drawNo := fmt.Sprintf("SMASHEGG%d", drawID) + records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected) + if err != nil { + return nil, err + } + + if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil { + _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) + return nil, mapWalletError("smash_egg_wallet_deduct_failed", err) + } + + for index := range records { + record := &records[index] + if err := s.grantReward(ctx, record); err != nil { + _ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error()) + return nil, NewAppError(http.StatusBadGateway, "smash_egg_reward_grant_failed", err.Error()) + } + record.Status = drawStatusSuccess + record.UpdateTime = time.Now() + if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil { + return nil, err + } + } + + var balanceAfter int64 + if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { + balanceAfter = balanceMap[user.UserID] + } + + return buildDrawResponse(drawNo, user.UserID, sysOrigin, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records), nil +} + +func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.SmashEggDrawOptionConfig, error) { + if times <= 0 && len(options) > 0 { + return options[0], nil + } + for _, option := range options { + if option.Enabled && option.Times == times { + return option, nil + } + } + return model.SmashEggDrawOptionConfig{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option is not configured") +} + +func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) { + total := 0 + for _, reward := range rewards { + if reward.Enabled && reward.Probability > 0 { + total += reward.Probability + } + } + if total <= 0 { + return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") + } + + n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) + if err != nil { + return model.SmashEggRewardConfig{}, err + } + target := int(n.Int64()) + 1 + accumulated := 0 + for _, reward := range rewards { + if !reward.Enabled || reward.Probability <= 0 { + continue + } + accumulated += reward.Probability + if target <= accumulated { + return reward, nil + } + } + return rewards[len(rewards)-1], nil +} + +func (s *Service) createPendingRecords( + ctx context.Context, + drawNo string, + userID int64, + sysOrigin string, + poolType string, + option model.SmashEggDrawOptionConfig, + rewards []model.SmashEggRewardConfig, +) ([]model.SmashEggDrawRecord, error) { + now := time.Now() + records := make([]model.SmashEggDrawRecord, 0, len(rewards)) + for index, reward := range rewards { + nextID, err := utils.NextID() + if err != nil { + return nil, err + } + records = append(records, model.SmashEggDrawRecord{ + ID: nextID, + DrawNo: drawNo, + EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1), + SysOrigin: sysOrigin, + UserID: userID, + PoolType: normalizePoolType(poolType), + DrawOptionID: option.ID, + DrawTimes: option.Times, + PaidGold: option.PriceGold, + RewardType: reward.RewardType, + RewardGroupID: reward.RewardGroupID, + RewardGroupName: reward.RewardGroupName, + ResourceType: reward.ResourceType, + ResourceURL: reward.ResourceURL, + CoverURL: reward.CoverURL, + AnimationURL: reward.AnimationURL, + DurationDays: reward.DurationDays, + DisplayGoldAmount: reward.DisplayGoldAmount, + GoldAmount: reward.GoldAmount, + RewardValueGold: reward.RewardValueGold, + Probability: reward.Probability, + Status: drawStatusPending, + CreateTime: now, + UpdateTime: now, + }) + } + if len(records) == 0 { + return records, nil + } + if err := s.db.WithContext(ctx).Create(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error { + if paidGold <= 0 { + return nil + } + return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptExpenditure, + UserID: userID, + SysOrigin: sysOrigin, + EventID: fmt.Sprintf("%s:PAY", drawNo), + Remark: fmt.Sprintf("smash egg draw %s", drawNo), + Amount: integration.NewPennyAmountPayloadFromDollar(paidGold), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOrigin, + CustomizeOriginDesc: walletOriginDesc, + }) +} + +func (s *Service) grantReward(ctx context.Context, record *model.SmashEggDrawRecord) error { + switch normalizeRewardType(record.RewardType) { + case rewardTypeGold: + if record.GoldAmount <= 0 { + return fmt.Errorf("gold reward amount is empty") + } + return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptIncome, + UserID: record.UserID, + SysOrigin: record.SysOrigin, + EventID: record.EventID, + Remark: fmt.Sprintf("smash egg reward %s", record.DrawNo), + Amount: integration.NewPennyAmountPayloadFromDollar(record.GoldAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOrigin, + CustomizeOriginDesc: walletOriginDesc, + }) + case rewardTypeResource: + return s.grantResourceReward(ctx, record) + default: + return fmt.Errorf("unsupported reward type %s", record.RewardType) + } +} + +func (s *Service) grantResourceReward(ctx context.Context, record *model.SmashEggDrawRecord) error { + if record.RewardGroupID == nil || *record.RewardGroupID <= 0 { + return fmt.Errorf("resource reward is empty") + } + days := record.DurationDays + if days <= 0 { + return fmt.Errorf("resource reward duration is empty") + } + resourceID := *record.RewardGroupID + resourceType := strings.ToUpper(strings.TrimSpace(record.ResourceType)) + if resourceType == "" { + return fmt.Errorf("resource reward type is empty") + } + if resourceType == resourceTypeBadge || resourceType == resourceTypeRoomBadge { + if err := s.java.ActivateTemporaryBadge(ctx, record.UserID, resourceID, days); err != nil { + return err + } + _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) + return nil + } + + useProps := resourceType != resourceTypeGift + if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ + AcceptUserID: record.UserID, + PropsID: resourceID, + Type: resourceType, + Origin: walletOrigin, + OriginDesc: walletOriginDesc, + Days: days, + UseProps: useProps, + AllowGive: false, + }); err != nil { + return err + } + if useProps { + if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil { + return err + } + _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) + } + return nil +} + +func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error { + return s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Where("draw_no = ?", drawNo). + Updates(map[string]any{ + "status": status, + "error_message": trimErrorMessage(message), + "update_time": time.Now(), + }).Error +} + +func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error { + return s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "error_message": trimErrorMessage(message), + "update_time": time.Now(), + }).Error +} + +func buildDrawResponse( + drawNo string, + userID int64, + sysOrigin string, + poolType string, + times int, + paidGold int64, + balanceAfter int64, + newbieRemainingDrawCount int, + records []model.SmashEggDrawRecord, +) *DrawResponse { + payloadRecords := make([]DrawRecordPayload, 0, len(records)) + for _, record := range records { + payloadRecords = append(payloadRecords, drawRecordPayload(record)) + } + rewards, rewardValue := aggregateRewards(records) + return &DrawResponse{ + DrawNo: drawNo, + UserID: userID, + SysOrigin: sysOrigin, + PoolType: normalizePoolType(poolType), + DrawTimes: times, + PaidGold: paidGold, + RewardValue: rewardValue, + Rewards: rewards, + Records: payloadRecords, + BalanceAfter: balanceAfter, + NewbieRemainingDrawCount: newbieRemainingDrawCount, + } +} + +func aggregateRewards(records []model.SmashEggDrawRecord) ([]DrawRewardPayload, int64) { + type bucket struct { + payload DrawRewardPayload + } + buckets := map[string]*bucket{} + order := make([]string, 0, len(records)) + var total int64 + for _, record := range records { + value := record.RewardValueGold + total += value + key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.RewardGroupID), record.ResourceType, record.GoldAmount, record.DurationDays) + if _, exists := buckets[key]; !exists { + name := strings.TrimSpace(record.RewardGroupName) + if name == "" && record.RewardType == rewardTypeGold { + name = "金币" + } + buckets[key] = &bucket{payload: DrawRewardPayload{ + RewardType: record.RewardType, + ResourceID: ResourceID(int64PtrValue(record.RewardGroupID)), + ResourceType: record.ResourceType, + ResourceName: record.RewardGroupName, + ResourceURL: record.ResourceURL, + RewardGroupID: record.RewardGroupID, + RewardGroupName: record.RewardGroupName, + CoverURL: record.CoverURL, + AnimationURL: record.AnimationURL, + DurationDays: record.DurationDays, + DisplayGoldAmount: record.DisplayGoldAmount, + GoldAmount: record.GoldAmount, + RewardValueGold: record.RewardValueGold, + Probability: record.Probability, + Name: name, + Value: value, + }} + order = append(order, key) + } + buckets[key].payload.Count++ + } + result := make([]DrawRewardPayload, 0, len(order)) + for _, key := range order { + result = append(result, buckets[key].payload) + } + return result, total +} + +func int64PtrValue(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func mapWalletError(code string, err error) *AppError { + message := err.Error() + normalized := strings.ToLower(message) + if strings.Contains(normalized, "insufficient_balance") || + strings.Contains(normalized, "balance not made") || + strings.Contains(normalized, `"errorcode":5000`) { + return NewAppError(http.StatusNotAcceptable, "smash_egg_insufficient_balance", "insufficient balance") + } + return NewAppError(http.StatusBadGateway, code, message) +} diff --git a/internal/service/smashegg/flow_integration_test.go b/internal/service/smashegg/flow_integration_test.go new file mode 100644 index 0000000..81a0365 --- /dev/null +++ b/internal/service/smashegg/flow_integration_test.go @@ -0,0 +1,234 @@ +//go:build integration + +package smashegg + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/repo" +) + +type realResourceRow struct { + ID int64 `gorm:"column:id"` + Type string `gorm:"column:type"` + Name string `gorm:"column:name"` + Cover string `gorm:"column:cover"` + SourceURL string `gorm:"column:source_url"` + AmountGold int64 `gorm:"column:amount_gold"` +} + +func TestRealDataAdminConfigThenDraw(t *testing.T) { + ctx := context.Background() + cfg := config.Load() + repository, err := repo.New(cfg) + if err != nil { + t.Fatalf("init repository: %v", err) + } + if err := repository.Ping(ctx); err != nil { + t.Fatalf("ping real dependencies: %v", err) + } + + gateways := integration.NewGateways(cfg) + service := NewService(cfg, repository.DB, &gateways) + sysOrigin := "LIKEI" + userID := realFlowUserID() + + resource := firstRealResource(t, ctx, repository, sysOrigin) + req := SaveConfigRequest{ + SysOrigin: sysOrigin, + Enabled: true, + Timezone: "Asia/Riyadh", + RTPBasisPoints: 9500, + NewbiePoolEnabled: true, + NewbieWindowDays: 90, + NewbieMaxDrawCount: 1000, + NewbieMinRechargeAmount: 0, + DrawOptions: []DrawOptionInput{ + {Enabled: true, Sort: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 500}, + {Enabled: true, Sort: 2, OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 5000}, + {Enabled: true, Sort: 3, OptionKey: optionKeyHundred, Label: "Smash 100", Times: 100, PriceGold: 50000}, + }, + Rewards: []RewardConfigInput{ + {Enabled: true, Sort: 1, RewardType: rewardTypeGold, GoldAmount: 50}, + {Enabled: true, Sort: 2, RewardType: rewardTypeGold, GoldAmount: 100}, + {Enabled: true, Sort: 3, RewardType: rewardTypeGold, GoldAmount: 500}, + { + Enabled: true, + Sort: 4, + RewardType: rewardTypeResource, + ResourceID: ResourceID(resource.ID), + ResourceType: resource.Type, + ResourceName: resource.Name, + ResourceURL: resource.SourceURL, + CoverURL: resource.Cover, + AnimationURL: resource.SourceURL, + DurationDays: 1, + DisplayGoldAmount: resource.AmountGold, + }, + }, + NewbieRewards: []RewardConfigInput{ + {Enabled: true, Sort: 1, RewardType: rewardTypeGold, GoldAmount: 400}, + {Enabled: true, Sort: 2, RewardType: rewardTypeGold, GoldAmount: 500}, + {Enabled: true, Sort: 3, RewardType: rewardTypeGold, GoldAmount: 600}, + }, + } + + configResp, err := service.SaveConfig(ctx, req) + if err != nil { + t.Fatalf("save admin config: %v", err) + } + if configResp.RTPBasisPoints != 9500 || len(configResp.DrawOptions) != 3 { + t.Fatalf("unexpected config response: rtp=%d options=%d", configResp.RTPBasisPoints, len(configResp.DrawOptions)) + } + assertProbabilityTotal(t, "general", configResp.Rewards) + assertProbabilityTotal(t, "newbie", configResp.NewbieRewards) + assertResourceRewardPresent(t, configResp.Rewards, resource.ID) + t.Logf("admin_config saved sysOrigin=%s rtp=%s options=%d generalRewards=%d newbieRewards=%d resource=%s:%d price=%d", + configResp.SysOrigin, configResp.RTPPercent, len(configResp.DrawOptions), len(configResp.Rewards), len(configResp.NewbieRewards), resource.Type, resource.ID, resource.AmountGold) + + appConfig, err := service.GetAppConfig(ctx, AuthUser{UserID: userID, SysOrigin: sysOrigin}) + if err != nil { + t.Fatalf("get app config: %v", err) + } + if appConfig.ActivePoolType != poolTypeNewbie || !appConfig.NewbieEligible { + t.Fatalf("expected newbie pool, got pool=%s eligible=%v remaining=%d", appConfig.ActivePoolType, appConfig.NewbieEligible, appConfig.NewbieRemainingDrawCount) + } + t.Logf("app_config activePool=%s newbieRemaining=%d drawOptions=%d", appConfig.ActivePoolType, appConfig.NewbieRemainingDrawCount, len(appConfig.DrawOptions)) + + balanceBefore, err := goldBalance(ctx, &gateways, userID) + if err != nil { + t.Fatalf("balance before: %v", err) + } + if balanceBefore < 5000 { + t.Fatalf("test user balance=%d is below draw price", balanceBefore) + } + + var recordsBefore int64 + if err := repository.DB.WithContext(ctx).Table("smash_egg_draw_record").Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).Count(&recordsBefore).Error; err != nil { + t.Fatalf("count records before: %v", err) + } + + drawResp, err := service.Draw(ctx, AuthUser{UserID: userID, SysOrigin: sysOrigin}, DrawRequest{Times: 10}) + if err != nil { + t.Fatalf("draw: %v", err) + } + if drawResp.DrawTimes != 10 || drawResp.PaidGold != 5000 || drawResp.PoolType != poolTypeNewbie { + t.Fatalf("unexpected draw response: times=%d paid=%d pool=%s", drawResp.DrawTimes, drawResp.PaidGold, drawResp.PoolType) + } + if len(drawResp.Records) != 10 || len(drawResp.Rewards) == 0 { + t.Fatalf("unexpected reward records: records=%d rewards=%d", len(drawResp.Records), len(drawResp.Rewards)) + } + for _, record := range drawResp.Records { + if record.Status != drawStatusSuccess { + t.Fatalf("record %d status=%s error=%s", record.ID, record.Status, record.ErrorMessage) + } + } + + balanceAfter, err := goldBalance(ctx, &gateways, userID) + if err != nil { + t.Fatalf("balance after: %v", err) + } + expectedBalanceAfter := balanceBefore - drawResp.PaidGold + drawResp.RewardValue + if balanceAfter != expectedBalanceAfter { + t.Fatalf("balance after=%d, want %d (before=%d paid=%d reward=%d)", balanceAfter, expectedBalanceAfter, balanceBefore, drawResp.PaidGold, drawResp.RewardValue) + } + + var recordsAfter int64 + if err := repository.DB.WithContext(ctx).Table("smash_egg_draw_record").Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).Count(&recordsAfter).Error; err != nil { + t.Fatalf("count records after: %v", err) + } + if recordsAfter-recordsBefore != int64(drawResp.DrawTimes) { + t.Fatalf("record delta=%d, want %d", recordsAfter-recordsBefore, drawResp.DrawTimes) + } + t.Logf("draw_success drawNo=%s paid=%d reward=%d balanceBefore=%d balanceAfter=%d recordsDelta=%d", + drawResp.DrawNo, drawResp.PaidGold, drawResp.RewardValue, balanceBefore, balanceAfter, recordsAfter-recordsBefore) + + adminRecords, err := service.PageAdminRecords(ctx, sysOrigin, userID, drawStatusSuccess, 1, 20, "", "") + if err != nil { + t.Fatalf("page admin records: %v", err) + } + if len(adminRecords.Records) == 0 || adminRecords.Records[0].DrawNo == "" { + t.Fatalf("admin records are empty after draw") + } + t.Logf("admin_records total=%d firstDrawNo=%s firstReward=%s", adminRecords.Total, adminRecords.Records[0].DrawNo, adminRecords.Records[0].RewardGroupName) +} + +func realFlowUserID() int64 { + raw := strings.TrimSpace(os.Getenv("SMASH_EGG_REAL_FLOW_USER_ID")) + if raw == "" { + return 2052720059744202753 + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed <= 0 { + return 2052720059744202753 + } + return parsed +} + +func firstRealResource(t *testing.T, ctx context.Context, repository *repo.Repository, sysOrigin string) realResourceRow { + t.Helper() + var row realResourceRow + err := repository.DB.WithContext(ctx).Raw( + `SELECT id, type, name, cover, source_url, CAST(amount AS SIGNED) AS amount_gold +FROM props_source_record +WHERE sys_origin = ? AND is_del = 0 AND amount > 0 AND amount < 300000 +ORDER BY amount ASC, id ASC +LIMIT 1`, + sysOrigin, + ).Scan(&row).Error + if err != nil { + t.Fatalf("query real resource: %v", err) + } + if row.ID <= 0 { + t.Fatalf("no real resource found for sysOrigin=%s", sysOrigin) + } + if strings.TrimSpace(row.Type) == "" { + t.Fatalf("real resource %d has empty type", row.ID) + } + if row.AmountGold <= 0 { + row.AmountGold = 5000 + } + return row +} + +func assertProbabilityTotal(t *testing.T, name string, rewards []RewardConfigPayload) { + t.Helper() + total := 0 + for _, reward := range rewards { + if reward.Enabled { + total += reward.Probability + } + } + if total != probabilityTotal { + t.Fatalf("%s probability total=%d, want %d", name, total, probabilityTotal) + } +} + +func assertResourceRewardPresent(t *testing.T, rewards []RewardConfigPayload, resourceID int64) { + t.Helper() + for _, reward := range rewards { + if reward.RewardType == rewardTypeResource && reward.ResourceID.Int64() == resourceID && reward.DisplayGoldAmount > 0 { + return + } + } + t.Fatalf("resource reward %d with display price was not found", resourceID) +} + +func goldBalance(ctx context.Context, gateways *integration.Gateways, userID int64) (int64, error) { + balances, err := gateways.MapGoldBalance(ctx, []int64{userID}) + if err != nil { + return 0, err + } + value, ok := balances[userID] + if !ok { + return 0, fmt.Errorf("missing balance for user %d", userID) + } + return value, nil +} diff --git a/internal/service/smashegg/legacy.go b/internal/service/smashegg/legacy.go new file mode 100644 index 0000000..1ef4a94 --- /dev/null +++ b/internal/service/smashegg/legacy.go @@ -0,0 +1,273 @@ +package smashegg + +import ( + "context" + "strconv" + "time" + + "chatapp3-golang/internal/model" +) + +const legacyGoldImage = "assets/common/yumi_coin.png" + +// LegacyPrizeList returns the shape expected by the copied H5 bundle. +func (s *Service) LegacyPrizeList(ctx context.Context, user AuthUser) (map[string]any, error) { + config, err := s.GetAppConfig(ctx, user) + if err != nil { + return nil, err + } + var balance int64 + if s.java != nil && user.UserID > 0 { + if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { + balance = balanceMap[user.UserID] + } + } + + drawOptions := config.DrawOptions + if len(drawOptions) == 0 { + drawOptions = defaultDrawOptionPayloads(0) + } + mapPrice := int64(0) + if len(drawOptions) > 0 && drawOptions[0].Times > 0 { + mapPrice = drawOptions[0].PriceGold / int64(drawOptions[0].Times) + } + return map[string]any{ + "activity": map[string]any{"enabled": config.Enabled, "rtp": config.RTPPercent}, + "coins": balance, + "drawOptions": legacyDrawOptions(drawOptions), + "mapPrice": mapPrice, + "normalList": legacyPrizeList(config.Rewards), + "notices": []any{}, + "owner": map[string]any{}, + "rankData": map[string]any{}, + "superList": []any{}, + "topOne": map[string]any{}, + }, nil +} + +// LegacyStartHunt executes draw and returns the shape expected by the H5 bundle. +func (s *Service) LegacyStartHunt(ctx context.Context, user AuthUser, times int) (map[string]any, error) { + resp, err := s.Draw(ctx, user, DrawRequest{Times: times}) + if err != nil { + return nil, err + } + return map[string]any{ + "balance": resp.BalanceAfter, + "list": legacyRewardList(resp.Rewards), + }, nil +} + +// LegacyMyHuntRecords returns the user's draw records in the H5 bundle shape. +func (s *Service) LegacyMyHuntRecords(ctx context.Context, user AuthUser, start int, limit int) (map[string]any, error) { + if start < 0 { + start = 0 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + var rows []model.SmashEggDrawRecord + if err := s.db.WithContext(ctx). + Where("sys_origin = ? AND user_id = ?", normalizeSysOrigin(user.SysOrigin), user.UserID). + Order("create_time desc, id desc"). + Offset(start). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + + grouped := make([]map[string]any, 0) + index := map[string]int{} + for _, row := range rows { + if _, exists := index[row.DrawNo]; !exists { + index[row.DrawNo] = len(grouped) + grouped = append(grouped, map[string]any{ + "huntTime": strconv.FormatInt(row.CreateTime.Unix(), 10), + "prizeList": []map[string]any{}, + }) + } + itemIndex := index[row.DrawNo] + list := grouped[itemIndex]["prizeList"].([]map[string]any) + grouped[itemIndex]["prizeList"] = append(list, legacyRecordPrize(row)) + } + return map[string]any{ + "finished": len(rows) < limit, + "list": grouped, + }, nil +} + +// LegacyHuntNotices returns recent notices in the H5 bundle shape. +func (s *Service) LegacyHuntNotices(ctx context.Context, sysOrigin string) (map[string]any, error) { + notices, err := s.ListRecentNotices(ctx, sysOrigin, 20) + if err != nil { + return nil, err + } + list := make([]map[string]any, 0, len(notices)) + for _, item := range notices { + list = append(list, map[string]any{ + "nick": "User " + strconv.FormatInt(item.UserID, 10), + "prizeList": []map[string]any{{ + "image": legacyRewardImage(item.RewardType, item.CoverURL), + "num": legacyRewardNum(item.GoldAmount), + }}, + }) + } + return map[string]any{"list": list}, nil +} + +// LegacyDayRank returns day rank in the H5 bundle shape. +func (s *Service) LegacyDayRank(ctx context.Context, sysOrigin string) (map[string]any, error) { + rank, err := s.PageDayRank(ctx, sysOrigin, 1, 100) + if err != nil { + return nil, err + } + list := make([]map[string]any, 0, len(rank)) + for _, item := range rank { + list = append(list, map[string]any{ + "avatar": "", + "gotPrices": item.Score, + "nick": "User " + strconv.FormatInt(item.UserID, 10), + "uid": item.UserID, + }) + } + return map[string]any{"list": list}, nil +} + +func legacyDrawOptions(options []DrawOptionPayload) []map[string]any { + result := make([]map[string]any, 0, len(options)) + for _, option := range options { + if !option.Enabled { + continue + } + result = append(result, map[string]any{ + "key": option.OptionKey, + "label": option.Label, + "price": option.PriceGold, + "times": option.Times, + }) + } + return result +} + +func legacyPrizeList(rewards []RewardConfigPayload) []map[string]any { + result := make([]map[string]any, 0, len(rewards)) + for _, reward := range rewards { + if !reward.Enabled { + continue + } + result = append(result, map[string]any{ + "id": firstNonZeroInt64(reward.ResourceID.Int64(), int64PtrValue(reward.RewardGroupID), reward.GoldAmount), + "image": legacyRewardImage(reward.RewardType, reward.CoverURL), + "name": legacyRewardName(reward.RewardGroupName, reward.RewardType), + "num": legacyRewardNum(reward.GoldAmount), + "price": legacyDisplayPrice(reward.RewardType, reward.GoldAmount, reward.DisplayGoldAmount, reward.RewardValueGold), + "type": legacyRewardType(reward.RewardType), + "animationUrl": reward.AnimationURL, + }) + } + return result +} + +func legacyRewardList(rewards []DrawRewardPayload) []map[string]any { + result := make([]map[string]any, 0, len(rewards)) + for _, reward := range rewards { + result = append(result, map[string]any{ + "id": firstNonZeroInt64(int64PtrValue(reward.RewardGroupID), reward.GoldAmount), + "image": legacyRewardImage(reward.RewardType, reward.CoverURL), + "name": legacyRewardName(reward.RewardGroupName, reward.RewardType), + "num": legacyDrawRewardNum(reward), + "price": legacyDisplayPrice(reward.RewardType, reward.GoldAmount, reward.DisplayGoldAmount, reward.RewardValueGold), + "type": legacyRewardType(reward.RewardType), + "animationUrl": reward.AnimationURL, + }) + } + return result +} + +func legacyRecordPrize(row model.SmashEggDrawRecord) map[string]any { + return map[string]any{ + "id": firstNonZeroInt64(int64PtrValue(row.RewardGroupID), row.GoldAmount), + "image": legacyRewardImage(row.RewardType, row.CoverURL), + "name": legacyRewardName(row.RewardGroupName, row.RewardType), + "num": legacyRewardNum(row.GoldAmount), + "price": legacyDisplayPrice(row.RewardType, row.GoldAmount, row.DisplayGoldAmount, row.RewardValueGold), + "type": legacyRewardType(row.RewardType), + "animationUrl": row.AnimationURL, + } +} + +func legacyRewardImage(rewardType string, coverURL string) string { + if rewardType == rewardTypeGold { + return legacyGoldImage + } + if coverURL != "" { + return coverURL + } + return legacyGoldImage +} + +func legacyRewardName(name string, rewardType string) string { + if name != "" { + return name + } + if rewardType == rewardTypeGold { + return "Gold" + } + return "Reward" +} + +func legacyRewardNum(goldAmount int64) int64 { + if goldAmount > 0 { + return goldAmount + } + return 1 +} + +func legacyDrawRewardNum(reward DrawRewardPayload) int64 { + if reward.RewardType == rewardTypeGold { + return legacyRewardNum(reward.GoldAmount) + } + return int64(maxInt(1, reward.Count)) +} + +func legacyRewardType(rewardType string) int { + if rewardType == rewardTypeGold { + return 1 + } + return 2 +} + +func legacyDisplayPrice(rewardType string, goldAmount int64, displayGoldAmount int64, rewardValueGold int64) int64 { + if rewardType == rewardTypeGold && goldAmount > 0 { + return goldAmount + } + if displayGoldAmount > 0 { + return displayGoldAmount + } + return rewardValueGold +} + +func firstNonZeroInt64(values ...int64) int64 { + for _, value := range values { + if value > 0 { + return value + } + } + return time.Now().UnixNano() +} + +func maxInt(minimum int, value int) int { + if value < minimum { + return minimum + } + return value +} + +func maxInt64(minimum int64, value int64) int64 { + if value < minimum { + return minimum + } + return value +} diff --git a/internal/service/smashegg/newbie.go b/internal/service/smashegg/newbie.go new file mode 100644 index 0000000..81d13c0 --- /dev/null +++ b/internal/service/smashegg/newbie.go @@ -0,0 +1,123 @@ +package smashegg + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +type newbieEligibility struct { + Eligible bool + Remaining int +} + +func (item newbieEligibility) remainingAfterUse(poolType string) int { + if normalizePoolType(poolType) != poolTypeNewbie { + return item.Remaining + } + if item.Remaining <= 0 { + return 0 + } + return item.Remaining - 1 +} + +func (s *Service) resolveRewardPool(ctx context.Context, bundle *configBundle, user AuthUser) (string, []model.SmashEggRewardConfig, newbieEligibility, error) { + eligibility := newbieEligibility{} + if bundle == nil || bundle.Config == nil { + return poolTypeGeneral, nil, eligibility, nil + } + generalRewards := rewardsForPool(bundle.Rewards, poolTypeGeneral) + if !bundle.Config.NewbiePoolEnabled || user.UserID <= 0 { + return poolTypeGeneral, generalRewards, eligibility, nil + } + newbieRewards := rewardsForPool(bundle.Rewards, poolTypeNewbie) + if len(newbieRewards) == 0 { + return poolTypeGeneral, generalRewards, eligibility, nil + } + resolved, err := s.resolveNewbieEligibility(ctx, bundle.Config, user) + if err != nil { + return "", nil, eligibility, err + } + if !resolved.Eligible { + return poolTypeGeneral, generalRewards, resolved, nil + } + return poolTypeNewbie, newbieRewards, resolved, nil +} + +func (s *Service) resolveNewbieEligibility(ctx context.Context, configRow *model.SmashEggConfig, user AuthUser) (newbieEligibility, error) { + result := newbieEligibility{} + if configRow == nil || !configRow.NewbiePoolEnabled || user.UserID <= 0 { + return result, nil + } + + windowDays := effectiveNewbieWindowDays(configRow.NewbieWindowDays) + maxDrawCount := effectiveNewbieMaxDrawCount(configRow.NewbieMaxDrawCount, true) + if maxDrawCount <= 0 { + return result, nil + } + + var userInfo model.UserBaseInfo + err := s.db.WithContext(ctx).Where("id = ?", user.UserID).First(&userInfo).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return result, nil + } + if err != nil { + return result, err + } + if userInfo.CreateTime.IsZero() { + return result, nil + } + if time.Since(userInfo.CreateTime) > time.Duration(windowDays)*24*time.Hour { + return result, nil + } + + var used int64 + if err := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Where("sys_origin = ? AND user_id = ? AND pool_type = ? AND status IN ?", normalizeSysOrigin(user.SysOrigin), user.UserID, poolTypeNewbie, []string{drawStatusPending, drawStatusSuccess}). + Distinct("draw_no"). + Count(&used).Error; err != nil { + return result, err + } + remaining := maxDrawCount - int(used) + if remaining <= 0 { + return result, nil + } + + if configRow.NewbieMinRechargeAmount > 0 { + if s.java == nil { + return result, nil + } + rechargeMap, err := s.java.MapUserTotalRecharge(ctx, []int64{user.UserID}) + if err != nil { + return result, nil + } + if !totalRechargeAtLeast(rechargeMap[user.UserID], configRow.NewbieMinRechargeAmount) { + return result, nil + } + } + + result.Eligible = true + result.Remaining = remaining + return result, nil +} + +func totalRechargeAtLeast(items []integration.UserTotalRecharge, threshold int64) bool { + if threshold <= 0 { + return true + } + total := new(big.Rat) + for _, item := range items { + if strings.EqualFold(strings.TrimSpace(item.Type), "REFUND") { + continue + } + total.Add(total, parseDecimalRat(item.Amount)) + } + return total.Cmp(new(big.Rat).SetInt64(threshold)) >= 0 +} diff --git a/internal/service/smashegg/probability.go b/internal/service/smashegg/probability.go new file mode 100644 index 0000000..825e28a --- /dev/null +++ b/internal/service/smashegg/probability.go @@ -0,0 +1,243 @@ +package smashegg + +import ( + "fmt" + "math" + "net/http" + "sort" + + "chatapp3-golang/internal/model" +) + +type probabilityItem struct { + index int + value int64 +} + +func applyAutoProbabilitiesToInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]RewardConfigInput, error) { + if err := validateAutoProbabilitiesForInputs(targetRTP, options, rewards, poolName); err != nil { + return nil, err + } + baseOption, ok := firstEnabledInputOption(options) + if !ok { + return rewards, nil + } + probabilities, err := autoProbabilitiesForInputOption(targetRTP, baseOption, rewards, poolName) + if err != nil { + return nil, err + } + for index := range rewards { + rewards[index].Probability = probabilities[index] + } + return rewards, nil +} + +func validateAutoProbabilitiesForInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) error { + for _, option := range options { + if !option.Enabled { + continue + } + probabilities, err := autoProbabilitiesForInputOption(targetRTP, option, rewards, poolName) + if err != nil { + return err + } + weightedValue := weightedInputRewardValue(rewards, probabilities) + rtp := drawOptionRTPBasisPointsFromWeightedValue(weightedValue, option.Times, option.PriceGold) + if rtp < targetRTP-rtpToleranceBasisPoints || rtp > targetRTP+rtpToleranceBasisPoints { + return NewAppError( + http.StatusBadRequest, + "invalid_rtp", + fmt.Sprintf("%s pool option %s auto RTP %s%% is outside target %s%% ±1.00%%", poolName, option.Label, basisPointsPercent(rtp), basisPointsPercent(targetRTP)), + ) + } + } + return nil +} + +func rewardsWithAutoProbabilitiesForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) ([]model.SmashEggRewardConfig, error) { + probabilities, err := autoProbabilitiesForModelOption(targetRTP, option, rewards, normalizePoolType(firstPoolType(rewards))) + if err != nil { + return nil, err + } + next := append([]model.SmashEggRewardConfig(nil), rewards...) + for index := range next { + next[index].Probability = probabilities[index] + } + return next, nil +} + +func firstPoolType(rewards []model.SmashEggRewardConfig) string { + for _, reward := range rewards { + if reward.PoolType != "" { + return reward.PoolType + } + } + return poolTypeGeneral +} + +func expectedRewardValueForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) float64 { + next, err := rewardsWithAutoProbabilitiesForOption(targetRTP, option, rewards) + if err != nil { + return expectedRewardValue(rewards) + } + return expectedRewardValue(next) +} + +func autoProbabilitiesForInputOption(targetRTP int, option DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]int, error) { + items := make([]probabilityItem, 0, len(rewards)) + for index, reward := range rewards { + if reward.Enabled { + items = append(items, probabilityItem{index: index, value: reward.RewardValueGold}) + } + } + return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label) +} + +func autoProbabilitiesForModelOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig, poolName string) ([]int, error) { + items := make([]probabilityItem, 0, len(rewards)) + for index, reward := range rewards { + if reward.Enabled { + items = append(items, probabilityItem{index: index, value: reward.RewardValueGold}) + } + } + return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label) +} + +func calculateAutoProbabilities(targetWeighted int64, rewardCount int, items []probabilityItem, poolName string, optionLabel string) ([]int, error) { + probabilities := make([]int, rewardCount) + if len(items) == 0 { + return probabilities, NewAppError(http.StatusBadRequest, "empty_rewards", poolName+" pool requires at least one enabled reward") + } + if len(items) > probabilityTotal { + return probabilities, NewAppError(http.StatusBadRequest, "too_many_rewards", fmt.Sprintf("%s pool enabled reward count cannot exceed %d", poolName, probabilityTotal)) + } + for _, item := range items { + if item.value <= 0 { + return probabilities, NewAppError(http.StatusBadRequest, "invalid_reward_value", poolName+" pool enabled reward value must be greater than 0") + } + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].value == items[j].value { + return items[i].index < items[j].index + } + return items[i].value < items[j].value + }) + + var baseWeighted int64 + for _, item := range items { + probabilities[item.index] = 1 + baseWeighted += item.value + } + remaining := probabilityTotal - len(items) + if remaining == 0 { + return probabilities, nil + } + + residualTarget := targetWeighted - baseWeighted + minResidual := items[0].value * int64(remaining) + maxResidual := items[len(items)-1].value * int64(remaining) + if residualTarget < minResidual { + probabilities[items[0].index] += remaining + return probabilities, nil + } + if residualTarget > maxResidual { + probabilities[items[len(items)-1].index] += remaining + return probabilities, nil + } + + low := items[0] + high := items[len(items)-1] + for index, item := range items { + if item.value*int64(remaining) <= residualTarget { + low = item + if index+1 < len(items) { + high = items[index+1] + } else { + high = item + } + } + } + if low.value == high.value { + probabilities[low.index] += remaining + return probabilities, nil + } + + spread := high.value - low.value + highCount := roundDiv(residualTarget-low.value*int64(remaining), spread) + if highCount < 0 { + highCount = 0 + } + if highCount > int64(remaining) { + highCount = int64(remaining) + } + highCount = bestHighCount(residualTarget, low.value, high.value, int64(remaining), highCount) + lowCount := int64(remaining) - highCount + probabilities[low.index] += int(lowCount) + probabilities[high.index] += int(highCount) + return probabilities, nil +} + +func bestHighCount(target int64, lowValue int64, highValue int64, remaining int64, seed int64) int64 { + best := seed + bestDiff := weightedDiff(target, lowValue, highValue, remaining, seed) + for _, candidate := range []int64{seed - 1, seed + 1} { + if candidate < 0 || candidate > remaining { + continue + } + diff := weightedDiff(target, lowValue, highValue, remaining, candidate) + if diff < bestDiff { + best = candidate + bestDiff = diff + } + } + return best +} + +func weightedDiff(target int64, lowValue int64, highValue int64, remaining int64, highCount int64) int64 { + value := lowValue*remaining + (highValue-lowValue)*highCount + return absInt64(target - value) +} + +func weightedInputRewardValue(rewards []RewardConfigInput, probabilities []int) int64 { + var total int64 + for index, reward := range rewards { + if index >= len(probabilities) || !reward.Enabled || probabilities[index] <= 0 { + continue + } + total += reward.RewardValueGold * int64(probabilities[index]) + } + return total +} + +func targetWeightedValue(priceGold int64, times int, targetRTP int) int64 { + if priceGold <= 0 || times <= 0 || targetRTP <= 0 { + return 0 + } + return roundDiv(priceGold*int64(targetRTP)*int64(probabilityTotal), int64(times)*10000) +} + +func firstEnabledInputOption(options []DrawOptionInput) (DrawOptionInput, bool) { + for _, option := range options { + if option.Enabled { + return option, true + } + } + return DrawOptionInput{}, false +} + +func roundDiv(numerator int64, denominator int64) int64 { + if denominator == 0 { + return 0 + } + if numerator >= 0 { + return (numerator + denominator/2) / denominator + } + return -int64(math.Round(float64(-numerator) / float64(denominator))) +} + +func absInt64(value int64) int64 { + if value < 0 { + return -value + } + return value +} diff --git a/internal/service/smashegg/records.go b/internal/service/smashegg/records.go new file mode 100644 index 0000000..0128064 --- /dev/null +++ b/internal/service/smashegg/records.go @@ -0,0 +1,299 @@ +package smashegg + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +// PageUserRecords returns a user's own smash egg draw records. +func (s *Service) PageUserRecords(ctx context.Context, user AuthUser, cursor int, limit int) (*RecordPageResponse, error) { + cursor, limit = normalizeRecordPage(cursor, limit) + var total int64 + query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Where("sys_origin = ? AND user_id = ?", normalizeSysOrigin(user.SysOrigin), user.UserID) + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var rows []model.SmashEggDrawRecord + if err := query.Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + return &RecordPageResponse{ + Records: drawRecordPayloads(rows), + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +// PageAdminRecords returns admin smash egg draw records. +func (s *Service) PageAdminRecords(ctx context.Context, sysOrigin string, userID int64, status string, cursor int, limit int, startTime string, endTime string) (*RecordPageResponse, error) { + cursor, limit = normalizeRecordPage(cursor, limit) + filter, err := newAdminRecordFilter(sysOrigin, userID, status, startTime, endTime) + if err != nil { + return nil, err + } + query := s.adminRecordQuery(ctx, filter) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var rows []model.SmashEggDrawRecord + if err := s.adminRecordQuery(ctx, filter). + Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + summary, err := s.adminRecordSummary(ctx, filter) + if err != nil { + return nil, err + } + return &RecordPageResponse{ + Records: drawRecordPayloads(rows), + Total: total, + Current: cursor, + Size: limit, + TotalPaidGold: summary.TotalPaidGold, + TotalRewardGold: summary.TotalRewardGold, + ReturnRatioBasisPoints: summary.ReturnRatioBasisPoints, + ReturnRatioPercent: basisPointsPercent(summary.ReturnRatioBasisPoints), + StartTime: formatDateTime(filter.StartTime), + EndTime: formatDateTime(filter.EndTime), + }, nil +} + +type adminRecordFilter struct { + SysOrigin string + UserID int64 + Status string + StartTime time.Time + EndTime time.Time +} + +type adminRecordSummary struct { + TotalPaidGold int64 + TotalRewardGold int64 + ReturnRatioBasisPoints int +} + +func newAdminRecordFilter(sysOrigin string, userID int64, status string, startRaw string, endRaw string) (adminRecordFilter, error) { + start, end, err := parseAdminRecordTimeRange(startRaw, endRaw) + if err != nil { + return adminRecordFilter{}, err + } + return adminRecordFilter{ + SysOrigin: normalizeSysOrigin(sysOrigin), + UserID: userID, + Status: strings.ToUpper(strings.TrimSpace(status)), + StartTime: start, + EndTime: end, + }, nil +} + +func parseAdminRecordTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) { + startRaw = strings.TrimSpace(startRaw) + endRaw = strings.TrimSpace(endRaw) + now := time.Now() + if startRaw == "" && endRaw == "" { + return now.AddDate(0, 0, -7), now, nil + } + + var start time.Time + var end time.Time + var err error + if startRaw != "" { + start, err = parseAdminRecordTime(startRaw, false) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if endRaw != "" { + end, err = parseAdminRecordTime(endRaw, true) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if startRaw == "" { + start = end.AddDate(0, 0, -7) + } + if endRaw == "" { + end = start.AddDate(0, 0, 7) + } + if end.Before(start) { + return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime") + } + return start, end, nil +} + +func parseAdminRecordTime(raw string, endOfDay bool) (time.Time, error) { + for _, layout := range []string{ + "2006-01-02 15:04:05", + time.RFC3339, + "2006-01-02T15:04:05.000Z07:00", + "2006-01-02", + } { + var parsed time.Time + var err error + if strings.Contains(layout, "Z07:00") { + parsed, err = time.Parse(layout, raw) + } else { + parsed, err = time.ParseInLocation(layout, raw, time.Local) + } + if err == nil { + if layout == "2006-01-02" && endOfDay { + parsed = parsed.Add(24*time.Hour - time.Nanosecond) + } + return parsed, nil + } + } + return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw)) +} + +func (s *Service) adminRecordQuery(ctx context.Context, filter adminRecordFilter) *gorm.DB { + query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Where("create_time >= ? AND create_time <= ?", filter.StartTime, filter.EndTime) + if filter.SysOrigin != "" { + query = query.Where("sys_origin = ?", filter.SysOrigin) + } + if filter.UserID > 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + return query +} + +func (s *Service) adminRecordSummary(ctx context.Context, filter adminRecordFilter) (adminRecordSummary, error) { + var summary adminRecordSummary + if err := s.adminRecordQuery(ctx, filter). + Select("COALESCE(SUM(reward_value_gold), 0)"). + Scan(&summary.TotalRewardGold).Error; err != nil { + return summary, err + } + + drawPaid := s.adminRecordQuery(ctx, filter). + Select("draw_no, MAX(paid_gold) AS paid_gold"). + Group("draw_no") + if err := s.db.WithContext(ctx). + Table("(?) AS draw_paid", drawPaid). + Select("COALESCE(SUM(paid_gold), 0)"). + Scan(&summary.TotalPaidGold).Error; err != nil { + return summary, err + } + if summary.TotalPaidGold > 0 { + summary.ReturnRatioBasisPoints = int((summary.TotalRewardGold*10000 + summary.TotalPaidGold/2) / summary.TotalPaidGold) + } + return summary, nil +} + +// ListRecentNotices returns recent successful prize notices. +func (s *Service) ListRecentNotices(ctx context.Context, sysOrigin string, limit int) ([]DrawRecordPayload, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + var rows []model.SmashEggDrawRecord + query := s.db.WithContext(ctx).Where("status = ?", drawStatusSuccess) + if normalized := normalizeSysOrigin(sysOrigin); normalized != "" { + query = query.Where("sys_origin = ?", normalized) + } + if err := query.Order("create_time desc, id desc").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + return drawRecordPayloads(rows), nil +} + +// PageDayRank returns today's top users by reward value. +func (s *Service) PageDayRank(ctx context.Context, sysOrigin string, cursor int, limit int) ([]DayRankPayload, error) { + cursor, limit = normalizeRecordPage(cursor, limit) + normalized := normalizeSysOrigin(sysOrigin) + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.Add(24 * time.Hour) + type row struct { + UserID int64 + Score int64 + } + var rows []row + query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}). + Select("user_id, COALESCE(SUM(reward_value_gold), 0) AS score"). + Where("status = ? AND create_time >= ? AND create_time < ?", drawStatusSuccess, start, end) + if normalized != "" { + query = query.Where("sys_origin = ?", normalized) + } + if err := query.Group("user_id"). + Order("score desc, user_id asc"). + Offset((cursor - 1) * limit). + Limit(limit). + Scan(&rows).Error; err != nil { + return nil, err + } + result := make([]DayRankPayload, 0, len(rows)) + for _, item := range rows { + result = append(result, DayRankPayload{ + UserID: item.UserID, + Score: item.Score, + }) + } + return result, nil +} + +type DayRankPayload struct { + UserID int64 `json:"userId"` + Score int64 `json:"score"` +} + +func drawRecordPayloads(rows []model.SmashEggDrawRecord) []DrawRecordPayload { + records := make([]DrawRecordPayload, 0, len(rows)) + for _, row := range rows { + records = append(records, drawRecordPayload(row)) + } + return records +} + +func drawRecordPayload(row model.SmashEggDrawRecord) DrawRecordPayload { + return DrawRecordPayload{ + ID: row.ID, + DrawNo: row.DrawNo, + EventID: row.EventID, + UserID: row.UserID, + SysOrigin: row.SysOrigin, + PoolType: normalizePoolType(row.PoolType), + DrawOptionID: row.DrawOptionID, + DrawTimes: row.DrawTimes, + PaidGold: row.PaidGold, + RewardType: row.RewardType, + ResourceID: ResourceID(int64PtrValue(row.RewardGroupID)), + ResourceType: row.ResourceType, + ResourceName: row.RewardGroupName, + ResourceURL: row.ResourceURL, + RewardGroupID: row.RewardGroupID, + RewardGroupName: row.RewardGroupName, + CoverURL: row.CoverURL, + AnimationURL: row.AnimationURL, + DurationDays: row.DurationDays, + DisplayGoldAmount: row.DisplayGoldAmount, + GoldAmount: row.GoldAmount, + RewardValueGold: row.RewardValueGold, + Probability: row.Probability, + Status: row.Status, + ErrorMessage: row.ErrorMessage, + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + } +} diff --git a/internal/service/smashegg/records_test.go b/internal/service/smashegg/records_test.go new file mode 100644 index 0000000..3c93636 --- /dev/null +++ b/internal/service/smashegg/records_test.go @@ -0,0 +1,103 @@ +package smashegg + +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestPageAdminRecordsDefaultsToLastWeekAndSummarizesGold(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.SmashEggDrawRecord{}); err != nil { + t.Fatalf("migrate sqlite: %v", err) + } + + now := time.Now().Add(-time.Hour).Truncate(time.Second) + rows := []model.SmashEggDrawRecord{ + { + DrawNo: "draw-ten", + EventID: "draw-ten-1", + SysOrigin: "LIKEI", + UserID: 1001, + DrawTimes: 10, + PaidGold: 1000, + RewardType: rewardTypeGold, + GoldAmount: 100, + RewardValueGold: 100, + Status: drawStatusSuccess, + CreateTime: now, + UpdateTime: now, + }, + { + DrawNo: "draw-ten", + EventID: "draw-ten-2", + SysOrigin: "LIKEI", + UserID: 1001, + DrawTimes: 10, + PaidGold: 1000, + RewardType: rewardTypeGold, + GoldAmount: 200, + RewardValueGold: 200, + Status: drawStatusSuccess, + CreateTime: now, + UpdateTime: now, + }, + { + DrawNo: "draw-one", + EventID: "draw-one-1", + SysOrigin: "LIKEI", + UserID: 1002, + DrawTimes: 1, + PaidGold: 100, + RewardType: rewardTypeGold, + GoldAmount: 50, + RewardValueGold: 50, + Status: drawStatusSuccess, + CreateTime: now, + UpdateTime: now, + }, + { + DrawNo: "draw-old", + EventID: "draw-old-1", + SysOrigin: "LIKEI", + UserID: 1003, + DrawTimes: 1, + PaidGold: 100, + RewardType: rewardTypeGold, + GoldAmount: 100, + RewardValueGold: 100, + Status: drawStatusSuccess, + CreateTime: now.AddDate(0, 0, -8), + UpdateTime: now.AddDate(0, 0, -8), + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("insert records: %v", err) + } + + service := &Service{db: db} + resp, err := service.PageAdminRecords(context.Background(), "likei", 0, drawStatusSuccess, 1, 20, "", "") + if err != nil { + t.Fatalf("PageAdminRecords returned error: %v", err) + } + if resp.Total != 3 { + t.Fatalf("total = %d, want 3", resp.Total) + } + if resp.TotalPaidGold != 1100 { + t.Fatalf("total paid gold = %d, want 1100", resp.TotalPaidGold) + } + if resp.TotalRewardGold != 350 { + t.Fatalf("total reward gold = %d, want 350", resp.TotalRewardGold) + } + if resp.ReturnRatioBasisPoints != 3182 || resp.ReturnRatioPercent != "31.82" { + t.Fatalf("return ratio = %d/%s, want 3182/31.82", resp.ReturnRatioBasisPoints, resp.ReturnRatioPercent) + } +} diff --git a/internal/service/smashegg/simulation_test.go b/internal/service/smashegg/simulation_test.go new file mode 100644 index 0000000..06b41cb --- /dev/null +++ b/internal/service/smashegg/simulation_test.go @@ -0,0 +1,122 @@ +//go:build simulation + +package smashegg + +import ( + "fmt" + "testing" + + "chatapp3-golang/internal/model" +) + +func TestSimulateNewbiePoolRTP1000Users100000Draws(t *testing.T) { + const ( + userCount = 1000 + totalDraws = 100000 + pricePerDraw = int64(500) + targetRTPBasis = 9500 + ) + + option := model.SmashEggDrawOptionConfig{ + ID: 1, + OptionKey: optionKeyOne, + Label: "Smash 1", + Times: 1, + PriceGold: pricePerDraw, + Enabled: true, + } + rewardTiers := []model.SmashEggRewardConfig{ + {ID: 1, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "50 Gold", RewardValueGold: 50, GoldAmount: 50, Enabled: true}, + {ID: 2, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "100 Gold", RewardValueGold: 100, GoldAmount: 100, Enabled: true}, + {ID: 3, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "500 Gold", RewardValueGold: 500, GoldAmount: 500, Enabled: true}, + {ID: 4, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "5000 Gold", RewardValueGold: 5000, GoldAmount: 5000, Enabled: true}, + } + rewards, err := rewardsWithAutoProbabilitiesForOption(targetRTPBasis, option, rewardTiers) + if err != nil { + t.Fatalf("calculate auto probabilities failed: %v", err) + } + + totalProbability := 0 + for _, reward := range rewards { + totalProbability += reward.Probability + } + if totalProbability != probabilityTotal { + t.Fatalf("total probability = %d, want %d", totalProbability, probabilityTotal) + } + theoreticalValue := expectedRewardValue(rewards) + totalPaid := pricePerDraw * totalDraws + totalPayout := int64(0) + userPaid := make([]int64, userCount) + userPayout := make([]int64, userCount) + rewardHits := make(map[int64]int, len(rewards)) + + for drawIndex := 0; drawIndex < totalDraws; drawIndex++ { + userIndex := drawIndex % userCount + reward, err := pickReward(rewards) + if err != nil { + t.Fatalf("pickReward failed: %v", err) + } + totalPayout += reward.RewardValueGold + userPaid[userIndex] += pricePerDraw + userPayout[userIndex] += reward.RewardValueGold + rewardHits[reward.ID]++ + } + + actualRTP := float64(totalPayout) / float64(totalPaid) * 100 + theoreticalRTP := theoreticalValue / float64(pricePerDraw) * 100 + deviation := actualRTP - float64(targetRTPBasis)/100 + minUserRTP, maxUserRTP := userRTPRange(userPaid, userPayout) + if absFloat64(theoreticalRTP-float64(targetRTPBasis)/100) > 1 { + t.Fatalf("theoretical RTP %.4f%% is outside target %.2f%% ±1.00%%", theoreticalRTP, float64(targetRTPBasis)/100) + } + if absFloat64(deviation) > 1 { + t.Fatalf("actual RTP %.4f%% is outside target %.2f%% ±1.00%%", actualRTP, float64(targetRTPBasis)/100) + } + + t.Logf("users=%d totalDraws=%d pricePerDraw=%d random=crypto/rand no-seed", userCount, totalDraws, pricePerDraw) + t.Logf("targetRTP=%.2f%% theoreticalRTP=%.2f%% actualRTP=%.4f%% deviation=%+.4f%%", float64(targetRTPBasis)/100, theoreticalRTP, actualRTP, deviation) + t.Logf("totalPaid=%d totalPayout=%d net=%d", totalPaid, totalPayout, totalPayout-totalPaid) + t.Logf("perUserDraws=%d minUserRTP=%.2f%% maxUserRTP=%.2f%%", totalDraws/userCount, minUserRTP, maxUserRTP) + for _, reward := range rewards { + hits := rewardHits[reward.ID] + t.Logf( + "reward=%s value=%d probability=%s%% hits=%d hitRate=%.4f%%", + reward.RewardGroupName, + reward.RewardValueGold, + probabilityPercent(reward.Probability), + hits, + float64(hits)/float64(totalDraws)*100, + ) + } +} + +func userRTPRange(paid []int64, payout []int64) (float64, float64) { + minRTP := 0.0 + maxRTP := 0.0 + for index := range paid { + if paid[index] <= 0 { + continue + } + rtp := float64(payout[index]) / float64(paid[index]) * 100 + if minRTP == 0 || rtp < minRTP { + minRTP = rtp + } + if rtp > maxRTP { + maxRTP = rtp + } + } + return minRTP, maxRTP +} + +func absFloat64(value float64) float64 { + if value < 0 { + return -value + } + return value +} + +func Example_simulationCommand() { + fmt.Println("go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v") + // Output: + // go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v +} diff --git a/internal/service/smashegg/types.go b/internal/service/smashegg/types.go new file mode 100644 index 0000000..edc435d --- /dev/null +++ b/internal/service/smashegg/types.go @@ -0,0 +1,483 @@ +package smashegg + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + probabilityTotal = 100 * 10000 + + defaultTimezone = "Asia/Riyadh" + defaultRTPBasisPoints = 9500 + rtpToleranceBasisPoints = 100 + + defaultNewbieWindowDays = 7 + defaultNewbieMaxDrawCount = 3 + + poolTypeGeneral = "GENERAL" + poolTypeNewbie = "NEWBIE" + + optionKeyOne = "ONE" + optionKeyTen = "TEN" + optionKeyHundred = "HUNDRED" + + rewardTypeResource = "RESOURCE" + rewardTypeGold = "GOLD" + + resourceTypeBadge = "BADGE" + resourceTypeRoomBadge = "ROOM_BADGE" + resourceTypeGift = "GIFT" + + drawStatusPending = "PENDING" + drawStatusSuccess = "SUCCESS" + drawStatusFailed = "FAILED" + + walletReceiptIncome = "INCOME" + walletReceiptExpenditure = "EXPENDITURE" + walletOrigin = "SMASH_GOLDEN_EGG" + walletOriginDesc = "Smash golden egg" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type smashEggDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type gateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error + SwitchUseProps(ctx context.Context, userID int64, propsID int64) error + ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error + RemoveUserProfileCacheAll(ctx context.Context, userID int64) error + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) + MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) +} + +// ResourceID preserves Java int64 ids across JSON boundaries by emitting strings. +type ResourceID int64 + +func (id ResourceID) Int64() int64 { + return int64(id) +} + +func (id ResourceID) MarshalJSON() ([]byte, error) { + if id <= 0 { + return []byte("null"), nil + } + return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil +} + +func (id *ResourceID) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *id = 0 + return nil + } + if strings.HasPrefix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + if raw == "" { + *id = 0 + return nil + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return err + } + *id = ResourceID(parsed) + return nil +} + +// Service owns resident smash golden egg configuration, draw, and records APIs. +type Service struct { + cfg config.Config + db smashEggDB + java gateway +} + +// NewService creates a resident smash golden egg service. +func NewService(cfg config.Config, db smashEggDB, javaGateway gateway) *Service { + return &Service{cfg: cfg, db: db, java: javaGateway} +} + +// SaveConfigRequest is the admin payload for saving smash egg settings. +type SaveConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + RTPBasisPoints int `json:"rtpBasisPoints"` + NewbiePoolEnabled bool `json:"newbiePoolEnabled"` + NewbieWindowDays int `json:"newbieWindowDays"` + NewbieMaxDrawCount int `json:"newbieMaxDrawCount"` + NewbieMinRechargeAmount int64 `json:"newbieMinRechargeAmount"` + DrawOptions []DrawOptionInput `json:"drawOptions"` + Rewards []RewardConfigInput `json:"rewards"` + NewbieRewards []RewardConfigInput `json:"newbieRewards"` +} + +// DrawOptionInput is one button option submitted by admin. +type DrawOptionInput struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + OptionKey string `json:"optionKey"` + Label string `json:"label"` + Times int `json:"times"` + PriceGold int64 `json:"priceGold"` +} + +// RewardConfigInput is one reward row submitted by admin. +type RewardConfigInput struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + PoolType string `json:"poolType"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId"` + ResourceType string `json:"resourceType"` + ResourceName string `json:"resourceName"` + ResourceURL string `json:"resourceUrl"` + RewardGroupID *int64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` + CoverURL string `json:"coverUrl"` + AnimationURL string `json:"animationUrl"` + DurationDays int `json:"durationDays"` + DisplayGoldAmount int64 `json:"displayGoldAmount"` + GoldAmount int64 `json:"goldAmount"` + RewardValueGold int64 `json:"rewardValueGold"` + Probability int `json:"probability"` +} + +// ConfigResponse is returned to admin and app config readers. +type ConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + RTPBasisPoints int `json:"rtpBasisPoints"` + RTPPercent string `json:"rtpPercent"` + ProbabilityTotal int `json:"probabilityTotal"` + ExpectedValueGold string `json:"expectedValueGold"` + NewbiePoolEnabled bool `json:"newbiePoolEnabled"` + NewbieWindowDays int `json:"newbieWindowDays"` + NewbieMaxDrawCount int `json:"newbieMaxDrawCount"` + NewbieMinRechargeAmount int64 `json:"newbieMinRechargeAmount"` + NewbieExpectedValueGold string `json:"newbieExpectedValueGold,omitempty"` + ActivePoolType string `json:"activePoolType"` + NewbieEligible bool `json:"newbieEligible"` + NewbieRemainingDrawCount int `json:"newbieRemainingDrawCount"` + DrawOptions []DrawOptionPayload `json:"drawOptions"` + Rewards []RewardConfigPayload `json:"rewards"` + NewbieRewards []RewardConfigPayload `json:"newbieRewards,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// DrawOptionPayload is one configured draw button exposed to clients. +type DrawOptionPayload struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + OptionKey string `json:"optionKey"` + Label string `json:"label"` + Times int `json:"times"` + PriceGold int64 `json:"priceGold"` + RTPBasisPoints int `json:"rtpBasisPoints"` + RTPPercent string `json:"rtpPercent"` + ExpectedValueGold string `json:"expectedValueGold"` +} + +// RewardConfigPayload is one configured reward exposed to clients. +type RewardConfigPayload struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + PoolType string `json:"poolType"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + AnimationURL string `json:"animationUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + RewardValueGold int64 `json:"rewardValueGold,omitempty"` + Probability int `json:"probability"` + ProbabilityPercent string `json:"probabilityPercent"` + RewardItems []RewardGroupItem `json:"rewardItems,omitempty"` +} + +// RewardGroupItem mirrors Java reward group item details. +type RewardGroupItem 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"` +} + +// DrawRequest is an app draw request. +type DrawRequest struct { + Times int `json:"times"` +} + +// DrawResponse is the app draw result. +type DrawResponse struct { + DrawNo string `json:"drawNo"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + PoolType string `json:"poolType"` + DrawTimes int `json:"drawTimes"` + PaidGold int64 `json:"paidGold"` + RewardValue int64 `json:"rewardValue"` + Rewards []DrawRewardPayload `json:"rewards"` + Records []DrawRecordPayload `json:"records"` + BalanceAfter int64 `json:"balanceAfter,omitempty"` + NewbieRemainingDrawCount int `json:"newbieRemainingDrawCount,omitempty"` +} + +// DrawRewardPayload aggregates identical rewards in one draw response. +type DrawRewardPayload struct { + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + AnimationURL string `json:"animationUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + RewardValueGold int64 `json:"rewardValueGold,omitempty"` + Probability int `json:"probability,omitempty"` + Count int `json:"count"` + Name string `json:"name,omitempty"` + Value int64 `json:"value,omitempty"` +} + +// DrawRecordPayload is one draw record row. +type DrawRecordPayload struct { + ID int64 `json:"id"` + DrawNo string `json:"drawNo"` + EventID string `json:"eventId,omitempty"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + PoolType string `json:"poolType"` + DrawOptionID int64 `json:"drawOptionId"` + DrawTimes int `json:"drawTimes"` + PaidGold int64 `json:"paidGold"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + AnimationURL string `json:"animationUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + RewardValueGold int64 `json:"rewardValueGold,omitempty"` + Probability int `json:"probability"` + Status string `json:"status"` + ErrorMessage string `json:"errorMessage,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// RecordPageResponse is an admin or app record page. +type RecordPageResponse struct { + Records []DrawRecordPayload `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` + TotalPaidGold int64 `json:"totalPaidGold"` + TotalRewardGold int64 `json:"totalRewardGold"` + ReturnRatioBasisPoints int `json:"returnRatioBasisPoints"` + ReturnRatioPercent string `json:"returnRatioPercent"` + StartTime string `json:"startTime,omitempty"` + EndTime string `json:"endTime,omitempty"` +} + +func normalizeSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +func normalizeTimezone(timezone string) string { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return defaultTimezone + } + return timezone +} + +func normalizeRewardType(rewardType string) string { + switch strings.ToUpper(strings.TrimSpace(rewardType)) { + case "RESOURCE", "RESOURCE_GROUP", "PROPS": + return rewardTypeResource + case rewardTypeGold: + return rewardTypeGold + default: + return strings.ToUpper(strings.TrimSpace(rewardType)) + } +} + +func normalizePoolType(poolType string) string { + poolType = strings.ToUpper(strings.TrimSpace(poolType)) + switch poolType { + case poolTypeNewbie: + return poolTypeNewbie + default: + return poolTypeGeneral + } +} + +func normalizeOptionKey(optionKey string, index int) string { + optionKey = strings.ToUpper(strings.TrimSpace(optionKey)) + if optionKey != "" { + return optionKey + } + switch index { + case 0: + return optionKeyOne + case 1: + return optionKeyTen + case 2: + return optionKeyHundred + default: + return fmt.Sprintf("OPTION_%d", index+1) + } +} + +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func trimErrorMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) <= 1024 { + return message + } + return message[:1024] +} + +func ParseUserID(value string) int64 { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + parsed, _ := strconv.ParseInt(value, 10, 64) + return parsed +} + +func probabilityPercent(probability int) string { + if probability <= 0 { + return "0" + } + text := fmt.Sprintf("%.5f", float64(probability)*100/float64(probabilityTotal)) + text = strings.TrimRight(text, "0") + return strings.TrimRight(text, ".") +} + +func resourceIDPtr(value ResourceID) *int64 { + if value.Int64() <= 0 { + return nil + } + next := value.Int64() + return &next +} + +func basisPointsPercent(basisPoints int) string { + if basisPoints <= 0 { + return "0.00" + } + return fmt.Sprintf("%.2f", float64(basisPoints)/100) +} + +func amount2(value float64) string { + return fmt.Sprintf("%.2f", value) +} + +func parseDecimalRat(value integration.DecimalString) *big.Rat { + raw := strings.TrimSpace(string(value)) + if raw == "" { + return new(big.Rat) + } + result, ok := new(big.Rat).SetString(raw) + if !ok { + return new(big.Rat) + } + return result +} + +func normalizeRecordPage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func sortRewardInputs(rewards []RewardConfigInput) { + sort.SliceStable(rewards, func(i, j int) bool { + if rewards[i].Sort == rewards[j].Sort { + return rewards[i].ID < rewards[j].ID + } + return rewards[i].Sort < rewards[j].Sort + }) +} + +func sortOptionInputs(options []DrawOptionInput) { + sort.SliceStable(options, func(i, j int) bool { + if options[i].Sort == options[j].Sort { + return options[i].ID < options[j].ID + } + return options[i].Sort < options[j].Sort + }) +} + +func mustJSONString(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(data) +} diff --git a/internal/service/wheel/config.go b/internal/service/wheel/config.go new file mode 100644 index 0000000..803b1cd --- /dev/null +++ b/internal/service/wheel/config.go @@ -0,0 +1,623 @@ +package wheel + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +type configBundle struct { + Config *model.WheelConfig + Categories []model.WheelPoolConfig + Rewards []model.WheelRewardConfig +} + +// GetConfig returns admin wheel configuration. +func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { + bundle, err := s.loadConfigBundle(ctx, sysOrigin, false) + if err != nil { + return nil, err + } + if bundle.Config == nil { + return defaultConfigResponse(sysOrigin), nil + } + return s.buildConfigResponse(bundle) +} + +// GetAppConfig returns enabled wheel configuration for app clients. +func (s *Service) GetAppConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { + bundle, err := s.loadConfigBundle(ctx, user.SysOrigin, true) + if err != nil { + return nil, err + } + if bundle.Config == nil { + return &ConfigResponse{ + Configured: false, + SysOrigin: normalizeSysOrigin(user.SysOrigin), + Timezone: defaultTimezone, + ProbabilityTotal: probabilityTotal, + Categories: defaultCategoryPayloads(false), + }, nil + } + return s.buildConfigResponse(bundle) +} + +// SaveConfig saves admin wheel configuration. +func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + normalized, err := normalizeSaveRequest(req) + if err != nil { + return nil, err + } + configEnabled := false + for _, category := range normalized.Categories { + if category.Enabled { + configEnabled = true + break + } + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var configRow model.WheelConfig + if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.WheelConfig{ + ID: nextID, + SysOrigin: normalized.SysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + + configRow.Enabled = configEnabled + configRow.Timezone = normalized.Timezone + configRow.UpdateTime = now + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + if err := tx.Save(&configRow).Error; err != nil { + return err + } + + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelPoolConfig{}).Error; err != nil { + return err + } + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelRewardConfig{}).Error; err != nil { + return err + } + + poolRows := make([]model.WheelPoolConfig, 0, len(normalized.Categories)) + rewardRows := make([]model.WheelRewardConfig, 0, 28) + for _, category := range normalized.Categories { + meta, _ := categoryMeta(category.Category) + poolID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + poolRows = append(poolRows, model.WheelPoolConfig{ + ID: poolID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + Category: category.Category, + Enabled: category.Enabled, + PriceOneGold: category.PriceOneGold, + PriceTenGold: category.PriceTenGold, + PriceFiftyGold: category.PriceFiftyGold, + Sort: meta.Sort, + CreateTime: now, + UpdateTime: now, + }) + + for index, item := range category.Rewards { + nextID, rewardIDErr := utils.NextID() + if rewardIDErr != nil { + return rewardIDErr + } + sortValue := item.Sort + if sortValue <= 0 { + sortValue = index + 1 + } + rewardRows = append(rewardRows, model.WheelRewardConfig{ + ID: nextID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + Category: category.Category, + RewardType: item.RewardType, + ResourceID: resourceIDPtr(item.ResourceID), + ResourceType: item.ResourceType, + ResourceName: strings.TrimSpace(item.ResourceName), + ResourceURL: strings.TrimSpace(item.ResourceURL), + CoverURL: strings.TrimSpace(item.CoverURL), + DurationDays: item.DurationDays, + DisplayGoldAmount: item.DisplayGoldAmount, + GoldAmount: item.GoldAmount, + Probability: item.Probability, + Sort: sortValue, + Enabled: item.Enabled, + CreateTime: now, + UpdateTime: now, + }) + } + } + if len(poolRows) > 0 { + if err := tx.Create(&poolRows).Error; err != nil { + return err + } + } + if len(rewardRows) > 0 { + return tx.Create(&rewardRows).Error + } + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, normalized.SysOrigin) +} + +// SaveCategoryConfig saves one admin wheel category without touching the other categories. +func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfigRequest) (*ConfigResponse, error) { + normalized, err := normalizeSaveCategoryRequest(req) + if err != nil { + return nil, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var configRow model.WheelConfig + if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.WheelConfig{ + ID: nextID, + SysOrigin: normalized.SysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + + configRow.Timezone = normalized.Timezone + configRow.UpdateTime = now + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + if err := tx.Save(&configRow).Error; err != nil { + return err + } + + category := normalized.Category + meta, _ := categoryMeta(category.Category) + if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelPoolConfig{}).Error; err != nil { + return err + } + if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelRewardConfig{}).Error; err != nil { + return err + } + + poolID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + poolRow := model.WheelPoolConfig{ + ID: poolID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + Category: category.Category, + Enabled: category.Enabled, + PriceOneGold: category.PriceOneGold, + PriceTenGold: category.PriceTenGold, + PriceFiftyGold: category.PriceFiftyGold, + Sort: meta.Sort, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&poolRow).Error; err != nil { + return err + } + + rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards)) + for index, item := range category.Rewards { + nextID, rewardIDErr := utils.NextID() + if rewardIDErr != nil { + return rewardIDErr + } + sortValue := item.Sort + if sortValue <= 0 { + sortValue = index + 1 + } + rewardRows = append(rewardRows, model.WheelRewardConfig{ + ID: nextID, + ConfigID: configRow.ID, + SysOrigin: normalized.SysOrigin, + Category: category.Category, + RewardType: item.RewardType, + ResourceID: resourceIDPtr(item.ResourceID), + ResourceType: item.ResourceType, + ResourceName: strings.TrimSpace(item.ResourceName), + ResourceURL: strings.TrimSpace(item.ResourceURL), + CoverURL: strings.TrimSpace(item.CoverURL), + DurationDays: item.DurationDays, + DisplayGoldAmount: item.DisplayGoldAmount, + GoldAmount: item.GoldAmount, + Probability: item.Probability, + Sort: sortValue, + Enabled: item.Enabled, + CreateTime: now, + UpdateTime: now, + }) + } + if len(rewardRows) > 0 { + if err := tx.Create(&rewardRows).Error; err != nil { + return err + } + } + + var enabledPools int64 + if err := tx.Model(&model.WheelPoolConfig{}). + Where("config_id = ? AND enabled = ?", configRow.ID, true). + Count(&enabledPools).Error; err != nil { + return err + } + return tx.Model(&model.WheelConfig{}). + Where("id = ?", configRow.ID). + Updates(map[string]any{ + "enabled": enabledPools > 0, + "timezone": normalized.Timezone, + "update_time": now, + }).Error + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, normalized.SysOrigin) +} + +func normalizeSaveRequest(req SaveConfigRequest) (SaveConfigRequest, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + timezone := normalizeTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + inputByCategory := make(map[string]CategoryConfigInput, len(req.Categories)) + for _, category := range req.Categories { + normalizedCategory := normalizeCategory(category.Category) + if _, ok := categoryMeta(normalizedCategory); !ok { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") + } + category.Category = normalizedCategory + inputByCategory[normalizedCategory] = category + } + + categories := make([]CategoryConfigInput, 0, len(wheelCategories)) + for _, meta := range wheelCategories { + category, exists := inputByCategory[meta.Category] + if !exists { + category = defaultCategoryInput(meta) + } + category.Category = meta.Category + if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 { + return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category)) + } + normalizedRewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled) + if err != nil { + return SaveConfigRequest{}, err + } + category.Rewards = normalizedRewards + categories = append(categories, category) + } + + return SaveConfigRequest{ + SysOrigin: sysOrigin, + Enabled: req.Enabled, + Timezone: timezone, + Categories: categories, + }, nil +} + +func normalizeSaveCategoryRequest(req SaveCategoryConfigRequest) (SaveCategoryConfigRequest, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + timezone := normalizeTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + category := req.Category + category.Category = normalizeCategory(category.Category) + meta, ok := categoryMeta(category.Category) + if !ok { + return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") + } + if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 { + return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category)) + } + rewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled) + if err != nil { + return SaveCategoryConfigRequest{}, err + } + category.Rewards = rewards + return SaveCategoryConfigRequest{ + SysOrigin: sysOrigin, + Timezone: timezone, + Category: category, + }, nil +} + +func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, requireEnabled bool) ([]RewardConfigInput, error) { + if len(rewards) != meta.RewardCount { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_count", fmt.Sprintf("%s rewards must contain exactly %d items", meta.Category, meta.RewardCount)) + } + + normalized := append([]RewardConfigInput(nil), rewards...) + sortRewardInputs(normalized) + enabledProbability := 0 + enabledCount := 0 + for index := range normalized { + item := &normalized[index] + item.RewardType = normalizeRewardType(item.RewardType) + item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType)) + item.ResourceName = strings.TrimSpace(item.ResourceName) + item.ResourceURL = strings.TrimSpace(item.ResourceURL) + item.CoverURL = strings.TrimSpace(item.CoverURL) + if item.Sort <= 0 { + item.Sort = index + 1 + } + + switch item.RewardType { + case rewardTypeResource: + if requireEnabled && item.ResourceID.Int64() <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_resource", fmt.Sprintf("%s resource reward requires resourceId", meta.Category)) + } + if requireEnabled && item.ResourceType == "" { + return nil, NewAppError(http.StatusBadRequest, "invalid_resource_type", fmt.Sprintf("%s resource reward requires resourceType", meta.Category)) + } + if requireEnabled && item.DurationDays <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_duration_days", fmt.Sprintf("%s resource reward requires durationDays greater than 0", meta.Category)) + } + if requireEnabled && item.DisplayGoldAmount <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", fmt.Sprintf("%s resource reward display gold amount must be greater than 0", meta.Category)) + } + if item.ResourceID.Int64() > 0 && item.ResourceName == "" { + item.ResourceName = fmt.Sprintf("资源 %d", item.ResourceID.Int64()) + } + item.GoldAmount = 0 + case rewardTypeGold: + if requireEnabled && item.GoldAmount <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_gold_amount", fmt.Sprintf("%s gold reward requires goldAmount", meta.Category)) + } + item.ResourceID = 0 + item.ResourceType = "" + item.ResourceName = "金币" + item.ResourceURL = "" + item.CoverURL = "" + item.DurationDays = 0 + item.DisplayGoldAmount = 0 + default: + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD") + } + + if item.Probability < 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_probability", "probability must be greater than or equal to 0") + } + if item.Enabled { + enabledCount++ + if requireEnabled && item.Probability <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_probability", fmt.Sprintf("%s enabled reward probability must be greater than 0", meta.Category)) + } + enabledProbability += item.Probability + } + } + if requireEnabled { + if enabledCount != meta.RewardCount { + return nil, NewAppError(http.StatusBadRequest, "invalid_enabled_reward_count", fmt.Sprintf("%s must enable exactly %d rewards", meta.Category, meta.RewardCount)) + } + if enabledProbability != probabilityTotal { + return nil, NewAppError(http.StatusBadRequest, "invalid_probability_total", fmt.Sprintf("%s enabled reward probability total must equal 10000", meta.Category)) + } + } + return normalized, nil +} + +func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabledRewards bool) (*configBundle, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + var configRow model.WheelConfig + err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &configBundle{}, nil + } + if err != nil { + return nil, err + } + + var categoryRows []model.WheelPoolConfig + categoryQuery := s.db.WithContext(ctx). + Where("config_id = ?", configRow.ID). + Order("sort asc, id asc") + if err := categoryQuery.Find(&categoryRows).Error; err != nil { + return nil, err + } + + rewardQuery := s.db.WithContext(ctx). + Where("config_id = ?", configRow.ID). + Order("category asc, sort asc, id asc") + if onlyEnabledRewards { + rewardQuery = rewardQuery.Where("enabled = ?", true) + } + var rewardRows []model.WheelRewardConfig + if err := rewardQuery.Find(&rewardRows).Error; err != nil { + return nil, err + } + return &configBundle{Config: &configRow, Categories: categoryRows, Rewards: rewardRows}, nil +} + +func defaultConfigResponse(sysOrigin string) *ConfigResponse { + return &ConfigResponse{ + Configured: false, + SysOrigin: normalizeSysOrigin(sysOrigin), + Enabled: false, + Timezone: defaultTimezone, + ProbabilityTotal: probabilityTotal, + Categories: defaultCategoryPayloads(false), + } +} + +func defaultCategoryInput(meta CategoryMeta) CategoryConfigInput { + return CategoryConfigInput{ + Category: meta.Category, + Enabled: false, + PriceOneGold: meta.DefaultPriceOneGold, + PriceTenGold: meta.DefaultPriceTenGold, + PriceFiftyGold: meta.DefaultPriceFiftyGold, + Rewards: defaultRewardInputs(meta.RewardCount), + } +} + +func defaultRewardInputs(count int) []RewardConfigInput { + rewards := make([]RewardConfigInput, 0, count) + for index := 0; index < count; index++ { + rewards = append(rewards, RewardConfigInput{ + Enabled: true, + Sort: index + 1, + RewardType: rewardTypeResource, + Probability: 0, + }) + } + return rewards +} + +func defaultCategoryPayloads(enabled bool) []CategoryConfigPayload { + result := make([]CategoryConfigPayload, 0, len(wheelCategories)) + for _, meta := range wheelCategories { + rewards := make([]RewardConfigPayload, 0, meta.RewardCount) + for index := 0; index < meta.RewardCount; index++ { + rewards = append(rewards, RewardConfigPayload{ + Enabled: true, + Sort: index + 1, + RewardType: rewardTypeResource, + Probability: 0, + ProbabilityPercent: "0.00", + }) + } + result = append(result, CategoryConfigPayload{ + Category: meta.Category, + Label: meta.Label, + Enabled: enabled, + RewardCount: meta.RewardCount, + PriceOneGold: meta.DefaultPriceOneGold, + PriceTenGold: meta.DefaultPriceTenGold, + PriceFiftyGold: meta.DefaultPriceFiftyGold, + ProbabilityTotal: probabilityTotal, + Rewards: rewards, + }) + } + return result +} + +func (s *Service) buildConfigResponse(bundle *configBundle) (*ConfigResponse, error) { + configRow := bundle.Config + categoryByName := make(map[string]model.WheelPoolConfig, len(bundle.Categories)) + for _, row := range bundle.Categories { + categoryByName[normalizeCategory(row.Category)] = row + } + rewardsByCategory := make(map[string][]model.WheelRewardConfig, len(wheelCategories)) + for _, row := range bundle.Rewards { + category := normalizeCategory(row.Category) + rewardsByCategory[category] = append(rewardsByCategory[category], row) + } + + categories := make([]CategoryConfigPayload, 0, len(wheelCategories)) + for _, meta := range wheelCategories { + pool, exists := categoryByName[meta.Category] + if !exists { + pool = model.WheelPoolConfig{ + Category: meta.Category, + Enabled: false, + PriceOneGold: meta.DefaultPriceOneGold, + PriceTenGold: meta.DefaultPriceTenGold, + PriceFiftyGold: meta.DefaultPriceFiftyGold, + Sort: meta.Sort, + } + } + rewards := make([]RewardConfigPayload, 0, len(rewardsByCategory[meta.Category])) + for _, row := range rewardsByCategory[meta.Category] { + rewards = append(rewards, rewardPayloadFromConfig(row)) + } + for len(rewards) < meta.RewardCount { + rewards = append(rewards, RewardConfigPayload{ + Enabled: true, + Sort: len(rewards) + 1, + RewardType: rewardTypeResource, + Probability: 0, + ProbabilityPercent: "0.00", + }) + } + if len(rewards) > meta.RewardCount { + rewards = rewards[:meta.RewardCount] + } + categories = append(categories, CategoryConfigPayload{ + Category: meta.Category, + Label: meta.Label, + Enabled: pool.Enabled, + RewardCount: meta.RewardCount, + PriceOneGold: pool.PriceOneGold, + PriceTenGold: pool.PriceTenGold, + PriceFiftyGold: pool.PriceFiftyGold, + ProbabilityTotal: probabilityTotal, + Rewards: rewards, + }) + } + return &ConfigResponse{ + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Timezone: normalizeTimezone(configRow.Timezone), + ProbabilityTotal: probabilityTotal, + Categories: categories, + UpdateTime: formatDateTime(configRow.UpdateTime), + }, nil +} + +func rewardPayloadFromConfig(row model.WheelRewardConfig) RewardConfigPayload { + return RewardConfigPayload{ + ID: row.ID, + Enabled: row.Enabled, + Sort: row.Sort, + RewardType: row.RewardType, + ResourceID: resourceIDValue(row.ResourceID), + ResourceType: row.ResourceType, + ResourceName: row.ResourceName, + ResourceURL: row.ResourceURL, + CoverURL: row.CoverURL, + DurationDays: row.DurationDays, + DisplayGoldAmount: row.DisplayGoldAmount, + GoldAmount: row.GoldAmount, + Probability: row.Probability, + ProbabilityPercent: probabilityPercent(row.Probability), + } +} diff --git a/internal/service/wheel/config_test.go b/internal/service/wheel/config_test.go new file mode 100644 index 0000000..2901363 --- /dev/null +++ b/internal/service/wheel/config_test.go @@ -0,0 +1,173 @@ +package wheel + +import ( + "encoding/json" + "testing" +) + +func TestNormalizeSaveRequestRequiresCategoryProbabilityTotalWhenEnabled(t *testing.T) { + req := validSaveRequestForTest() + req.Categories[0].Rewards[0].Probability-- + + _, err := normalizeSaveRequest(req) + if err == nil { + t.Fatal("normalizeSaveRequest() expected probability total error") + } +} + +func TestNormalizeSaveRequestAllowsZeroProbabilityDraftWhenDisabled(t *testing.T) { + req := validSaveRequestForTest() + req.Enabled = false + for categoryIndex := range req.Categories { + req.Categories[categoryIndex].Enabled = false + for rewardIndex := range req.Categories[categoryIndex].Rewards { + req.Categories[categoryIndex].Rewards[rewardIndex].Probability = 0 + } + } + + if _, err := normalizeSaveRequest(req); err != nil { + t.Fatalf("normalizeSaveRequest() error = %v", err) + } +} + +func TestNormalizeSaveCategoryRequestOnlyValidatesTargetCategory(t *testing.T) { + req := SaveCategoryConfigRequest{ + SysOrigin: "yumi", + Timezone: "Asia/Riyadh", + Category: validCategoryForTest(wheelCategories[1]), + } + req.Category.Rewards[0].Probability-- + + if _, err := normalizeSaveCategoryRequest(req); err == nil { + t.Fatal("normalizeSaveCategoryRequest() expected probability total error") + } + + req.Category.Enabled = false + for index := range req.Category.Rewards { + req.Category.Rewards[index].Probability = 0 + req.Category.Rewards[index].ResourceID = 0 + } + if _, err := normalizeSaveCategoryRequest(req); err != nil { + t.Fatalf("normalizeSaveCategoryRequest() disabled draft error = %v", err) + } +} + +func TestNormalizeSaveRequestRequiresFixedRewardCount(t *testing.T) { + req := validSaveRequestForTest() + req.Categories[2].Rewards = req.Categories[2].Rewards[:11] + + _, err := normalizeSaveRequest(req) + if err == nil { + t.Fatal("normalizeSaveRequest() expected reward count error") + } +} + +func TestNormalizeSaveRequestNormalizesGoldReward(t *testing.T) { + req := validSaveRequestForTest() + req.Categories[0].Rewards[0] = RewardConfigInput{ + Enabled: true, + Sort: 1, + RewardType: "gold", + ResourceID: ResourceID(999), + ResourceType: "RIDE", + ResourceName: "", + GoldAmount: 500, + Probability: req.Categories[0].Rewards[0].Probability, + } + + normalized, err := normalizeSaveRequest(req) + if err != nil { + t.Fatalf("normalizeSaveRequest() error = %v", err) + } + if normalized.SysOrigin != "YUMI" { + t.Fatalf("SysOrigin = %q", normalized.SysOrigin) + } + reward := normalized.Categories[0].Rewards[0] + if reward.RewardType != rewardTypeGold { + t.Fatalf("RewardType = %q", reward.RewardType) + } + if reward.ResourceID.Int64() != 0 { + t.Fatal("gold reward should not keep resourceId") + } + if reward.ResourceName == "" { + t.Fatal("gold reward should get a display name") + } +} + +func TestNormalizeDrawTimes(t *testing.T) { + for _, times := range []int{0, 1, 10, 50} { + if _, err := normalizeDrawTimes(times); err != nil { + t.Fatalf("normalizeDrawTimes(%d) error = %v", times, err) + } + } + if _, err := normalizeDrawTimes(2); err == nil { + t.Fatal("normalizeDrawTimes(2) expected error") + } +} + +func TestWheelResourceIDAcceptsStringResourceID(t *testing.T) { + var payload RewardConfigInput + if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089"}`), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if payload.ResourceID.Int64() != 2045029471274201089 { + t.Fatalf("resource id = %d", payload.ResourceID.Int64()) + } + body, err := json.Marshal(RewardConfigPayload{ResourceID: payload.ResourceID}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if got := string(body); got != `{"id":0,"enabled":false,"sort":0,"rewardType":"","resourceId":"2045029471274201089","probability":0,"probabilityPercent":""}` { + t.Fatalf("json = %s", got) + } +} + +func validSaveRequestForTest() SaveConfigRequest { + categories := make([]CategoryConfigInput, 0, len(wheelCategories)) + for _, meta := range wheelCategories { + categories = append(categories, validCategoryForTest(meta)) + } + return SaveConfigRequest{ + SysOrigin: "yumi", + Enabled: true, + Timezone: "Asia/Riyadh", + Categories: categories, + } +} + +func validCategoryForTest(meta CategoryMeta) CategoryConfigInput { + probabilities := distributedProbabilityForTest(meta.RewardCount) + rewards := make([]RewardConfigInput, 0, meta.RewardCount) + for index := 0; index < meta.RewardCount; index++ { + rewards = append(rewards, RewardConfigInput{ + Enabled: true, + Sort: index + 1, + RewardType: rewardTypeResource, + ResourceID: ResourceID(meta.Sort*1000 + index + 1), + ResourceType: "RIDE", + ResourceName: meta.Label, + DurationDays: 7, + DisplayGoldAmount: int64(1000 + index), + Probability: probabilities[index], + }) + } + return CategoryConfigInput{ + Category: meta.Category, + Enabled: true, + PriceOneGold: meta.DefaultPriceOneGold, + PriceTenGold: meta.DefaultPriceTenGold, + PriceFiftyGold: meta.DefaultPriceFiftyGold, + Rewards: rewards, + } +} + +func distributedProbabilityForTest(count int) []int { + base := probabilityTotal / count + remainder := probabilityTotal % count + values := make([]int, count) + for index := range values { + values[index] = base + } + values[count-1] += remainder + return values +} diff --git a/internal/service/wheel/draw.go b/internal/service/wheel/draw.go new file mode 100644 index 0000000..97d94df --- /dev/null +++ b/internal/service/wheel/draw.go @@ -0,0 +1,461 @@ +package wheel + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" +) + +// Draw executes one wheel draw request for an authenticated user. +func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResponse, error) { + sysOrigin := normalizeSysOrigin(user.SysOrigin) + if sysOrigin == "" || user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") + } + if s.java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "wheel_gateway_unavailable", "reward gateway is unavailable") + } + roomID, err := s.resolveCurrentRoomID(ctx, user) + if err != nil { + return nil, err + } + category := normalizeCategory(req.Category) + if _, ok := categoryMeta(category); !ok { + return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") + } + + times, err := normalizeDrawTimes(req.Times) + if err != nil { + return nil, err + } + bundle, err := s.loadConfigBundle(ctx, sysOrigin, true) + if err != nil { + return nil, err + } + if bundle.Config == nil || !bundle.Config.Enabled { + return nil, NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled") + } + pool, rewards, err := selectDrawPool(bundle, category) + if err != nil { + return nil, err + } + if len(rewards) == 0 { + return nil, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured") + } + paidGold, err := priceForTimes(pool, times) + if err != nil { + return nil, err + } + + selected := make([]model.WheelRewardConfig, 0, times) + for index := 0; index < times; index++ { + reward, pickErr := pickReward(rewards) + if pickErr != nil { + return nil, pickErr + } + selected = append(selected, reward) + } + + drawID, err := utils.NextID() + if err != nil { + return nil, err + } + drawNo := fmt.Sprintf("WHEEL%d", drawID) + records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, category, times, paidGold, selected) + if err != nil { + return nil, err + } + + if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil { + _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) + return nil, mapWalletError("wheel_wallet_deduct_failed", err) + } + + for index := range records { + record := &records[index] + if err := s.grantReward(ctx, record); err != nil { + _ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error()) + return nil, NewAppError(http.StatusBadGateway, "wheel_reward_grant_failed", err.Error()) + } + record.Status = drawStatusSuccess + record.UpdateTime = time.Now() + if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil { + return nil, err + } + } + + var balanceAfter int64 + if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { + balanceAfter = balanceMap[user.UserID] + } + + return buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records), nil +} + +func (s *Service) resolveCurrentRoomID(ctx context.Context, user AuthUser) (int64, error) { + profile, err := s.java.GetRoomProfileByUserID(ctx, user.UserID) + if err != nil { + return 0, NewAppError(http.StatusBadGateway, "wheel_current_room_lookup_failed", err.Error()) + } + roomID := int64(profile.ID) + if roomID <= 0 { + return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_required", "current room is required") + } + if profile.SysOrigin != "" && !strings.EqualFold(profile.SysOrigin, user.SysOrigin) { + return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_sys_origin_mismatch", "current room sysOrigin does not match user") + } + return roomID, nil +} + +func selectDrawPool(bundle *configBundle, category string) (model.WheelPoolConfig, []model.WheelRewardConfig, error) { + var pool model.WheelPoolConfig + foundPool := false + for _, item := range bundle.Categories { + if normalizeCategory(item.Category) == category { + pool = item + foundPool = true + break + } + } + if !foundPool || !pool.Enabled { + return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_category_not_available", "wheel category is not enabled") + } + + rewards := make([]model.WheelRewardConfig, 0, len(bundle.Rewards)) + for _, item := range bundle.Rewards { + if normalizeCategory(item.Category) == category && item.Enabled { + rewards = append(rewards, item) + } + } + meta, _ := categoryMeta(category) + if len(rewards) != meta.RewardCount { + return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_rewards_incomplete", fmt.Sprintf("%s rewards must contain exactly %d enabled items", category, meta.RewardCount)) + } + for _, reward := range rewards { + if err := validateDrawableReward(reward); err != nil { + return model.WheelPoolConfig{}, nil, NewAppError(http.StatusInternalServerError, "wheel_reward_invalid", err.Error()) + } + } + return pool, rewards, nil +} + +func validateDrawableReward(reward model.WheelRewardConfig) error { + switch normalizeRewardType(reward.RewardType) { + case rewardTypeGold: + if reward.GoldAmount <= 0 { + return fmt.Errorf("gold reward amount is empty") + } + case rewardTypeResource: + if reward.ResourceID == nil || *reward.ResourceID <= 0 { + return fmt.Errorf("resource reward is empty") + } + if strings.TrimSpace(reward.ResourceType) == "" { + return fmt.Errorf("resource reward type is empty") + } + if reward.DurationDays <= 0 { + return fmt.Errorf("resource reward duration is empty") + } + default: + return fmt.Errorf("unsupported reward type %s", reward.RewardType) + } + return nil +} + +func priceForTimes(configRow model.WheelPoolConfig, times int) (int64, error) { + switch times { + case 1: + return configRow.PriceOneGold, nil + case 10: + return configRow.PriceTenGold, nil + case 50: + return configRow.PriceFiftyGold, nil + default: + return 0, NewAppError(http.StatusBadRequest, "invalid_draw_times", "times must be 1, 10, or 50") + } +} + +func pickReward(rewards []model.WheelRewardConfig) (model.WheelRewardConfig, error) { + total := 0 + for _, reward := range rewards { + if reward.Enabled && reward.Probability > 0 { + total += reward.Probability + } + } + if total <= 0 { + return model.WheelRewardConfig{}, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured") + } + + n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) + if err != nil { + return model.WheelRewardConfig{}, err + } + target := int(n.Int64()) + 1 + accumulated := 0 + for _, reward := range rewards { + if !reward.Enabled || reward.Probability <= 0 { + continue + } + accumulated += reward.Probability + if target <= accumulated { + return reward, nil + } + } + return rewards[len(rewards)-1], nil +} + +func (s *Service) createPendingRecords( + ctx context.Context, + drawNo string, + userID int64, + sysOrigin string, + category string, + times int, + paidGold int64, + rewards []model.WheelRewardConfig, +) ([]model.WheelDrawRecord, error) { + now := time.Now() + records := make([]model.WheelDrawRecord, 0, len(rewards)) + for index, reward := range rewards { + nextID, err := utils.NextID() + if err != nil { + return nil, err + } + records = append(records, model.WheelDrawRecord{ + ID: nextID, + DrawNo: drawNo, + EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1), + SysOrigin: sysOrigin, + Category: category, + UserID: userID, + DrawTimes: times, + PaidGold: paidGold, + RewardType: normalizeRewardType(reward.RewardType), + ResourceID: reward.ResourceID, + ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)), + ResourceName: reward.ResourceName, + ResourceURL: reward.ResourceURL, + CoverURL: reward.CoverURL, + DurationDays: reward.DurationDays, + DisplayGoldAmount: reward.DisplayGoldAmount, + GoldAmount: reward.GoldAmount, + Probability: reward.Probability, + Status: drawStatusPending, + CreateTime: now, + UpdateTime: now, + }) + } + if len(records) == 0 { + return records, nil + } + if err := s.db.WithContext(ctx).Create(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error { + if paidGold <= 0 { + return nil + } + return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptExpenditure, + UserID: userID, + SysOrigin: sysOrigin, + EventID: fmt.Sprintf("%s:PAY", drawNo), + Remark: fmt.Sprintf("wheel draw %s", drawNo), + Amount: integration.NewPennyAmountPayloadFromDollar(paidGold), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOrigin, + CustomizeOriginDesc: walletOriginDesc, + }) +} + +func (s *Service) grantReward(ctx context.Context, record *model.WheelDrawRecord) error { + switch normalizeRewardType(record.RewardType) { + case rewardTypeGold: + if record.GoldAmount <= 0 { + return fmt.Errorf("gold reward amount is empty") + } + return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: walletReceiptIncome, + UserID: record.UserID, + SysOrigin: record.SysOrigin, + EventID: record.EventID, + Remark: fmt.Sprintf("wheel reward %s", record.DrawNo), + Amount: integration.NewPennyAmountPayloadFromDollar(record.GoldAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOrigin, + CustomizeOriginDesc: walletOriginDesc, + }) + case rewardTypeResource: + return s.grantResourceReward(ctx, record) + default: + return fmt.Errorf("unsupported reward type %s", record.RewardType) + } +} + +func (s *Service) grantResourceReward(ctx context.Context, record *model.WheelDrawRecord) error { + if record.ResourceID == nil || *record.ResourceID <= 0 { + return fmt.Errorf("resource reward is empty") + } + days := record.DurationDays + if days <= 0 { + return fmt.Errorf("resource reward duration is empty") + } + resourceID := *record.ResourceID + resourceType := strings.ToUpper(strings.TrimSpace(record.ResourceType)) + if resourceType == "" { + return fmt.Errorf("resource reward type is empty") + } + if resourceType == resourceTypeBadge || resourceType == resourceTypeRoomBadge { + if err := s.java.ActivateTemporaryBadge(ctx, record.UserID, resourceID, days); err != nil { + return err + } + _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) + return nil + } + + useProps := resourceType != resourceTypeGift + if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ + AcceptUserID: record.UserID, + PropsID: resourceID, + Type: resourceType, + Origin: walletOrigin, + OriginDesc: walletOriginDesc, + Days: days, + UseProps: useProps, + AllowGive: false, + }); err != nil { + return err + } + if useProps { + if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil { + return err + } + _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) + } + return nil +} + +func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error { + return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). + Where("draw_no = ?", drawNo). + Updates(map[string]any{ + "status": status, + "error_message": trimErrorMessage(message), + "update_time": time.Now(), + }).Error +} + +func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error { + return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "error_message": trimErrorMessage(message), + "update_time": time.Now(), + }).Error +} + +func buildDrawResponse( + drawNo string, + userID int64, + sysOrigin string, + roomID int64, + category string, + times int, + paidGold int64, + balanceAfter int64, + records []model.WheelDrawRecord, +) *DrawResponse { + payloadRecords := make([]DrawRecordPayload, 0, len(records)) + for _, record := range records { + payloadRecords = append(payloadRecords, drawRecordPayload(record)) + } + rewards, rewardValue := aggregateRewards(records) + return &DrawResponse{ + DrawNo: drawNo, + UserID: userID, + SysOrigin: sysOrigin, + RoomID: roomID, + Category: category, + DrawTimes: times, + PaidGold: paidGold, + RewardValue: rewardValue, + Rewards: rewards, + Records: payloadRecords, + BalanceAfter: balanceAfter, + } +} + +func aggregateRewards(records []model.WheelDrawRecord) ([]DrawRewardPayload, int64) { + type bucket struct { + payload DrawRewardPayload + } + buckets := map[string]*bucket{} + order := make([]string, 0, len(records)) + var total int64 + for _, record := range records { + value := record.GoldAmount + if record.RewardType != rewardTypeGold { + value = record.DisplayGoldAmount + } + total += value + key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.ResourceID), record.ResourceType, record.GoldAmount, record.DisplayGoldAmount) + if _, exists := buckets[key]; !exists { + name := strings.TrimSpace(record.ResourceName) + if name == "" && record.RewardType == rewardTypeGold { + name = "金币" + } + buckets[key] = &bucket{payload: DrawRewardPayload{ + RewardType: record.RewardType, + ResourceID: resourceIDValue(record.ResourceID), + ResourceType: record.ResourceType, + ResourceName: record.ResourceName, + ResourceURL: record.ResourceURL, + CoverURL: record.CoverURL, + DurationDays: record.DurationDays, + DisplayGoldAmount: record.DisplayGoldAmount, + GoldAmount: record.GoldAmount, + Probability: record.Probability, + Name: name, + Value: value, + }} + order = append(order, key) + } + buckets[key].payload.Count++ + } + result := make([]DrawRewardPayload, 0, len(order)) + for _, key := range order { + result = append(result, buckets[key].payload) + } + return result, total +} + +func int64PtrValue(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func mapWalletError(code string, err error) *AppError { + message := err.Error() + normalized := strings.ToLower(message) + if strings.Contains(normalized, "insufficient_balance") || + strings.Contains(normalized, "balance not made") || + strings.Contains(normalized, `"errorcode":5000`) { + return NewAppError(http.StatusNotAcceptable, "wheel_insufficient_balance", "insufficient balance") + } + return NewAppError(http.StatusBadGateway, code, message) +} diff --git a/internal/service/wheel/probability_test.go b/internal/service/wheel/probability_test.go new file mode 100644 index 0000000..8bb870b --- /dev/null +++ b/internal/service/wheel/probability_test.go @@ -0,0 +1,58 @@ +package wheel + +import ( + "math" + "testing" + + "chatapp3-golang/internal/model" +) + +func TestPickRewardDistribution1000000Draws(t *testing.T) { + const ( + draws = 1000000 + maxAllowedDriftPercent = 1.0 + ) + + rewards := []model.WheelRewardConfig{ + {ID: 1, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Coral Crown", Probability: 3000}, + {ID: 2, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Aurora Wing", Probability: 2200}, + {ID: 3, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Moon Card", Probability: 1500}, + {ID: 4, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Snow Pet", Probability: 1200}, + {ID: 5, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Love Banner", Probability: 900}, + {ID: 6, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Emerald Ring", Probability: 600}, + {ID: 7, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Royal Chest", Probability: 400}, + {ID: 8, Enabled: true, RewardType: rewardTypeGold, ResourceName: "Gold", Probability: 200}, + } + + counts := make(map[int64]int, len(rewards)) + for index := 0; index < draws; index++ { + reward, err := pickReward(rewards) + if err != nil { + t.Fatalf("pickReward() error = %v", err) + } + counts[reward.ID]++ + } + + for _, reward := range rewards { + expectedPercent := float64(reward.Probability) / probabilityTotal * 100 + actualPercent := float64(counts[reward.ID]) / draws * 100 + driftPercent := math.Abs(actualPercent - expectedPercent) + t.Logf( + "reward=%s configured=%.2f%% actual=%.2f%% drift=%.2f%% hits=%d/%d", + reward.ResourceName, + expectedPercent, + actualPercent, + driftPercent, + counts[reward.ID], + draws, + ) + if driftPercent > maxAllowedDriftPercent { + t.Fatalf( + "reward %s probability drift %.2f%% exceeds %.2f%%", + reward.ResourceName, + driftPercent, + maxAllowedDriftPercent, + ) + } + } +} diff --git a/internal/service/wheel/records.go b/internal/service/wheel/records.go new file mode 100644 index 0000000..97e0f3b --- /dev/null +++ b/internal/service/wheel/records.go @@ -0,0 +1,156 @@ +package wheel + +import ( + "context" + "strings" + + "chatapp3-golang/internal/model" +) + +// PageRecords returns admin draw records. +func (s *Service) PageRecords( + ctx context.Context, + sysOrigin string, + category string, + userID int64, + status string, + cursor int, + limit int, +) (*RecordPageResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(400, "bad_request", "sysOrigin is required") + } + cursor, limit = normalizeRecordPage(cursor, limit) + + query := s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). + Where("sys_origin = ?", sysOrigin) + if strings.TrimSpace(category) != "" { + query = query.Where("category = ?", normalizeCategory(category)) + } + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if status != "" { + query = query.Where("status = ?", status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.WheelDrawRecord + if err := query.Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + + records := make([]DrawRecordPayload, 0, len(rows)) + for _, row := range rows { + records = append(records, drawRecordPayload(row)) + } + return &RecordPageResponse{ + Records: records, + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +// PageUserHistory returns app draw history for the authenticated user. +func (s *Service) PageUserHistory(ctx context.Context, user AuthUser, cursor int, limit int) (*RecordPageResponse, error) { + return s.PageRecords(ctx, user.SysOrigin, "", user.UserID, drawStatusSuccess, cursor, limit) +} + +// Hints returns recent successful rewards for ticker displays. +func (s *Service) Hints(ctx context.Context, user AuthUser, category string, limit int) ([]DrawRewardPayload, error) { + if limit <= 0 { + limit = 20 + } + if limit > 50 { + limit = 50 + } + query := s.db.WithContext(ctx). + Where("sys_origin = ? AND status = ?", normalizeSysOrigin(user.SysOrigin), drawStatusSuccess). + Order("create_time desc, id desc"). + Limit(limit) + if strings.TrimSpace(category) != "" { + query = query.Where("category = ?", normalizeCategory(category)) + } + var rows []model.WheelDrawRecord + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + rewards := make([]DrawRewardPayload, 0, len(rows)) + for _, row := range rows { + name := row.ResourceName + if name == "" && row.RewardType == rewardTypeGold { + name = "金币" + } + rewards = append(rewards, DrawRewardPayload{ + RewardType: row.RewardType, + ResourceID: resourceIDValue(row.ResourceID), + ResourceType: row.ResourceType, + ResourceName: row.ResourceName, + ResourceURL: row.ResourceURL, + CoverURL: row.CoverURL, + DurationDays: row.DurationDays, + DisplayGoldAmount: row.DisplayGoldAmount, + GoldAmount: row.GoldAmount, + Probability: row.Probability, + Count: 1, + Name: name, + Value: displayValueForRecord(row), + }) + } + return rewards, nil +} + +// WalletBalances returns current gold balance in the shape needed by the H5 compatibility API. +func (s *Service) WalletBalances(ctx context.Context, user AuthUser) (int64, error) { + if s.java == nil { + return 0, nil + } + balances, err := s.java.MapGoldBalance(ctx, []int64{user.UserID}) + if err != nil { + return 0, err + } + return balances[user.UserID], nil +} + +func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload { + return DrawRecordPayload{ + ID: row.ID, + DrawNo: row.DrawNo, + EventID: row.EventID, + UserID: row.UserID, + SysOrigin: row.SysOrigin, + Category: row.Category, + DrawTimes: row.DrawTimes, + PaidGold: row.PaidGold, + RewardType: row.RewardType, + ResourceID: resourceIDValue(row.ResourceID), + ResourceType: row.ResourceType, + ResourceName: row.ResourceName, + ResourceURL: row.ResourceURL, + CoverURL: row.CoverURL, + DurationDays: row.DurationDays, + DisplayGoldAmount: row.DisplayGoldAmount, + GoldAmount: row.GoldAmount, + Probability: row.Probability, + Status: row.Status, + ErrorMessage: row.ErrorMessage, + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + } +} + +func displayValueForRecord(row model.WheelDrawRecord) int64 { + if normalizeRewardType(row.RewardType) == rewardTypeGold { + return row.GoldAmount + } + return row.DisplayGoldAmount +} diff --git a/internal/service/wheel/types.go b/internal/service/wheel/types.go new file mode 100644 index 0000000..df14a66 --- /dev/null +++ b/internal/service/wheel/types.go @@ -0,0 +1,429 @@ +package wheel + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + probabilityTotal = 10000 + + defaultTimezone = "Asia/Riyadh" + + categoryClassic = "CLASSIC" + categoryLuxury = "LUXURY" + categoryAdvanced = "ADVANCED" + + rewardTypeResource = "RESOURCE" + rewardTypeGold = "GOLD" + + resourceTypeBadge = "BADGE" + resourceTypeRoomBadge = "ROOM_BADGE" + resourceTypeGift = "GIFT" + + drawStatusPending = "PENDING" + drawStatusSuccess = "SUCCESS" + drawStatusFailed = "FAILED" + + walletReceiptIncome = "INCOME" + walletReceiptExpenditure = "EXPENDITURE" + walletOrigin = "YUMI_WHEEL" + walletOriginDesc = "Yumi wheel" +) + +var wheelCategories = []CategoryMeta{ + {Category: categoryClassic, Label: "Classic", RewardCount: 8, Sort: 1, DefaultPriceOneGold: 500, DefaultPriceTenGold: 5000, DefaultPriceFiftyGold: 25000}, + {Category: categoryLuxury, Label: "Luxury", RewardCount: 8, Sort: 2, DefaultPriceOneGold: 2000, DefaultPriceTenGold: 20000, DefaultPriceFiftyGold: 100000}, + {Category: categoryAdvanced, Label: "Advanced", RewardCount: 12, Sort: 3, DefaultPriceOneGold: 10000, DefaultPriceTenGold: 100000, DefaultPriceFiftyGold: 500000}, +} + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +// ResourceID preserves Java int64 ids across JSON boundaries by emitting strings. +type ResourceID int64 + +func (id ResourceID) Int64() int64 { + return int64(id) +} + +func (id ResourceID) MarshalJSON() ([]byte, error) { + if id <= 0 { + return []byte("null"), nil + } + return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil +} + +func (id *ResourceID) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *id = 0 + return nil + } + if strings.HasPrefix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + if raw == "" { + *id = 0 + return nil + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return err + } + *id = ResourceID(parsed) + return nil +} + +type wheelDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type wheelGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error + SwitchUseProps(ctx context.Context, userID int64, propsID int64) error + ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error + RemoveUserProfileCacheAll(ctx context.Context, userID int64) error + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) + GetRoomProfileByUserID(ctx context.Context, userID int64) (integration.RoomProfile, error) +} + +// Service owns resident wheel configuration, draw, and record APIs. +type Service struct { + cfg config.Config + db wheelDB + java wheelGateway +} + +// NewService creates a resident wheel service. +func NewService(cfg config.Config, db wheelDB, javaGateway wheelGateway) *Service { + return &Service{cfg: cfg, db: db, java: javaGateway} +} + +// CategoryMeta describes a fixed wheel category. +type CategoryMeta struct { + Category string `json:"category"` + Label string `json:"label"` + RewardCount int `json:"rewardCount"` + Sort int `json:"sort"` + DefaultPriceOneGold int64 `json:"defaultPriceOneGold"` + DefaultPriceTenGold int64 `json:"defaultPriceTenGold"` + DefaultPriceFiftyGold int64 `json:"defaultPriceFiftyGold"` +} + +// SaveConfigRequest is the admin payload for saving wheel settings. +type SaveConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + Categories []CategoryConfigInput `json:"categories"` +} + +// SaveCategoryConfigRequest saves one wheel category independently. +type SaveCategoryConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Timezone string `json:"timezone"` + Category CategoryConfigInput `json:"category"` +} + +// CategoryConfigInput is the admin payload for one wheel category. +type CategoryConfigInput struct { + Category string `json:"category"` + Enabled bool `json:"enabled"` + PriceOneGold int64 `json:"priceOneGold"` + PriceTenGold int64 `json:"priceTenGold"` + PriceFiftyGold int64 `json:"priceFiftyGold"` + Rewards []RewardConfigInput `json:"rewards"` +} + +// RewardConfigInput is one wheel reward row submitted by admin. +type RewardConfigInput struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId"` + ResourceType string `json:"resourceType"` + ResourceName string `json:"resourceName"` + ResourceURL string `json:"resourceUrl"` + CoverURL string `json:"coverUrl"` + DurationDays int `json:"durationDays"` + DisplayGoldAmount int64 `json:"displayGoldAmount"` + GoldAmount int64 `json:"goldAmount"` + Probability int `json:"probability"` +} + +// ConfigResponse is returned to admin and app config readers. +type ConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + ProbabilityTotal int `json:"probabilityTotal"` + Categories []CategoryConfigPayload `json:"categories"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// CategoryConfigPayload is one wheel category exposed to admin and app clients. +type CategoryConfigPayload struct { + Category string `json:"category"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + RewardCount int `json:"rewardCount"` + PriceOneGold int64 `json:"priceOneGold"` + PriceTenGold int64 `json:"priceTenGold"` + PriceFiftyGold int64 `json:"priceFiftyGold"` + ProbabilityTotal int `json:"probabilityTotal"` + Rewards []RewardConfigPayload `json:"rewards"` +} + +// RewardConfigPayload is one configured wheel reward exposed to clients. +type RewardConfigPayload struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + Probability int `json:"probability"` + ProbabilityPercent string `json:"probabilityPercent"` +} + +// DrawRequest is an app draw request. +type DrawRequest struct { + Category string `json:"category"` + Times int `json:"times"` +} + +// DrawResponse is the app draw result. +type DrawResponse struct { + DrawNo string `json:"drawNo"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + RoomID int64 `json:"roomId,string,omitempty"` + Category string `json:"category"` + DrawTimes int `json:"drawTimes"` + PaidGold int64 `json:"paidGold"` + RewardValue int64 `json:"rewardValue"` + Rewards []DrawRewardPayload `json:"rewards"` + Records []DrawRecordPayload `json:"records"` + BalanceAfter int64 `json:"balanceAfter,omitempty"` +} + +// DrawRewardPayload aggregates identical rewards in one draw response. +type DrawRewardPayload struct { + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + Probability int `json:"probability,omitempty"` + Count int `json:"count"` + Name string `json:"name,omitempty"` + Value int64 `json:"value,omitempty"` +} + +// DrawRecordPayload is one draw record row. +type DrawRecordPayload struct { + ID int64 `json:"id"` + DrawNo string `json:"drawNo"` + EventID string `json:"eventId,omitempty"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + Category string `json:"category"` + DrawTimes int `json:"drawTimes"` + PaidGold int64 `json:"paidGold"` + RewardType string `json:"rewardType"` + ResourceID ResourceID `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceName string `json:"resourceName,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + DurationDays int `json:"durationDays,omitempty"` + DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` + Probability int `json:"probability"` + Status string `json:"status"` + ErrorMessage string `json:"errorMessage,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// RecordPageResponse is an admin record page. +type RecordPageResponse struct { + Records []DrawRecordPayload `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +func normalizeSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +func normalizeTimezone(timezone string) string { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return defaultTimezone + } + return timezone +} + +func normalizeCategory(category string) string { + switch strings.ToUpper(strings.TrimSpace(category)) { + case "", "LUCKY-BOX", "LUCKY_BOX", "CLASSIC": + return categoryClassic + case "MIDDLE", "MIDDLE-LUCKY-BOX", "MIDDLE_LUCKY_BOX", "LUXURY": + return categoryLuxury + case "ADVANCED", "ADVANCED-LUCKY-BOX", "ADVANCED_LUCKY_BOX": + return categoryAdvanced + default: + return strings.ToUpper(strings.TrimSpace(category)) + } +} + +func categoryMeta(category string) (CategoryMeta, bool) { + category = normalizeCategory(category) + for _, item := range wheelCategories { + if item.Category == category { + return item, true + } + } + return CategoryMeta{}, false +} + +func categoryMetaMap() map[string]CategoryMeta { + result := make(map[string]CategoryMeta, len(wheelCategories)) + for _, item := range wheelCategories { + result[item.Category] = item + } + return result +} + +func normalizeRewardType(rewardType string) string { + switch strings.ToUpper(strings.TrimSpace(rewardType)) { + case "RESOURCE", "RESOURCE_GROUP", "PROPS": + return rewardTypeResource + case rewardTypeGold: + return rewardTypeGold + default: + return strings.ToUpper(strings.TrimSpace(rewardType)) + } +} + +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func trimErrorMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) <= 1024 { + return message + } + return message[:1024] +} + +func resourceIDPtr(value ResourceID) *int64 { + if value.Int64() <= 0 { + return nil + } + id := value.Int64() + return &id +} + +func resourceIDValue(value *int64) ResourceID { + if value == nil || *value <= 0 { + return 0 + } + return ResourceID(*value) +} + +func ParseUserID(value string) int64 { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + parsed, _ := strconv.ParseInt(value, 10, 64) + return parsed +} + +func probabilityPercent(probability int) string { + if probability <= 0 { + return "0.00" + } + return fmt.Sprintf("%.2f", float64(probability)/100) +} + +func normalizeDrawTimes(times int) (int, error) { + switch times { + case 0: + return 1, nil + case 1, 10, 50: + return times, nil + default: + return 0, NewAppError(http.StatusBadRequest, "invalid_draw_times", "times must be 1, 10, or 50") + } +} + +func normalizeRecordPage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func sortRewardInputs(rewards []RewardConfigInput) { + sort.SliceStable(rewards, func(i, j int) bool { + if rewards[i].Sort == rewards[j].Sort { + return rewards[i].ID < rewards[j].ID + } + return rewards[i].Sort < rewards[j].Sort + }) +} + +func mustJSONString(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(data) +} diff --git a/migrations/033_resident_wheel.sql b/migrations/033_resident_wheel.sql new file mode 100644 index 0000000..8951f66 --- /dev/null +++ b/migrations/033_resident_wheel.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS `wheel_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wheel_config_sys_origin` (`sys_origin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘配置'; + +CREATE TABLE IF NOT EXISTS `wheel_pool_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `price_one_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽1次价格,金币', + `price_ten_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽10次价格,金币', + `price_fifty_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽50次价格,金币', + `sort` int NOT NULL DEFAULT '0' COMMENT '排序', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wheel_pool_config` (`config_id`, `category`), + KEY `idx_wheel_pool_sys_origin` (`sys_origin`, `category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘分类配置'; + +CREATE TABLE IF NOT EXISTS `wheel_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED', + `reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD', + `resource_id` bigint DEFAULT NULL COMMENT '资源ID', + `resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型', + `resource_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源名称快照', + `resource_url` varchar(512) NOT NULL DEFAULT '' COMMENT '资源动效URL快照', + `cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图', + `duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数', + `display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量', + `probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率,万分比', + `sort` int NOT NULL DEFAULT '0' COMMENT '排序', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_wheel_reward_config` (`config_id`, `category`, `sort`), + KEY `idx_wheel_reward_enabled` (`sys_origin`, `category`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘奖励配置'; + +CREATE TABLE IF NOT EXISTS `wheel_draw_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `draw_no` varchar(64) NOT NULL COMMENT '抽奖单号', + `event_id` varchar(128) NOT NULL COMMENT '奖励发放幂等事件ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED', + `user_id` bigint NOT NULL COMMENT '用户ID', + `draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数', + `paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币', + `reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD', + `resource_id` bigint DEFAULT NULL COMMENT '资源ID快照', + `resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型快照', + `resource_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源名称快照', + `resource_url` varchar(512) NOT NULL DEFAULT '' COMMENT '资源动效URL快照', + `cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图快照', + `duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数快照', + `display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格快照', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照', + `probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,万分比', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED', + `error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wheel_draw_record_event` (`event_id`), + KEY `idx_wheel_draw_record_draw_no` (`draw_no`), + KEY `idx_wheel_draw_record_category` (`category`), + KEY `idx_wheel_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`), + KEY `idx_wheel_draw_record_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘抽奖记录'; diff --git a/migrations/034_resident_smash_egg.sql b/migrations/034_resident_smash_egg.sql new file mode 100644 index 0000000..7162339 --- /dev/null +++ b/migrations/034_resident_smash_egg.sql @@ -0,0 +1,81 @@ +CREATE TABLE IF NOT EXISTS `smash_egg_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区', + `rtp_basis_points` int NOT NULL DEFAULT '9500' COMMENT '目标RTP,万分比', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_smash_egg_config_sys_origin` (`sys_origin`), + KEY `idx_smash_egg_config_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋配置'; + +CREATE TABLE IF NOT EXISTS `smash_egg_draw_option_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `option_key` varchar(32) NOT NULL DEFAULT '' COMMENT '按钮标识', + `label` varchar(64) NOT NULL DEFAULT '' COMMENT '按钮文案', + `times` int NOT NULL DEFAULT '1' COMMENT '抽奖次数', + `price_gold` bigint NOT NULL DEFAULT '0' COMMENT '该按钮消耗金币', + `sort` int NOT NULL DEFAULT '0' COMMENT '排序', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_smash_egg_option_config` (`config_id`, `sort`), + KEY `idx_smash_egg_option_enabled` (`sys_origin`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋按钮配置'; + +CREATE TABLE IF NOT EXISTS `smash_egg_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE_GROUP/GOLD', + `reward_group_id` bigint DEFAULT NULL COMMENT '资源组ID', + `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源组名称快照', + `cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图', + `animation_url` varchar(512) NOT NULL DEFAULT '' COMMENT '动态图', + `duration_days` int NOT NULL DEFAULT '0' COMMENT '资源组展示天数', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量', + `reward_value_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖品折算金币价值,用于RTP计算', + `probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率,100%=1000000', + `sort` int NOT NULL DEFAULT '0' COMMENT '排序', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_smash_egg_reward_config` (`config_id`, `sort`), + KEY `idx_smash_egg_reward_enabled` (`sys_origin`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋奖励配置'; + +CREATE TABLE IF NOT EXISTS `smash_egg_draw_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `draw_no` varchar(64) NOT NULL COMMENT '抽奖单号', + `event_id` varchar(128) NOT NULL COMMENT '奖励发放幂等事件ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `draw_option_id` bigint NOT NULL DEFAULT '0' COMMENT '按钮配置ID', + `draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数', + `paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币', + `reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE_GROUP/GOLD', + `reward_group_id` bigint DEFAULT NULL COMMENT '资源组ID', + `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源组名称快照', + `cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图快照', + `animation_url` varchar(512) NOT NULL DEFAULT '' COMMENT '动态图快照', + `duration_days` int NOT NULL DEFAULT '0' COMMENT '天数快照', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照', + `reward_value_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖品折算金币价值', + `probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,100%=1000000', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED', + `error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_smash_egg_draw_record_event` (`event_id`), + KEY `idx_smash_egg_draw_record_draw_no` (`draw_no`), + KEY `idx_smash_egg_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`), + KEY `idx_smash_egg_draw_record_day` (`sys_origin`, `create_time`), + KEY `idx_smash_egg_draw_record_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋抽奖记录'; diff --git a/migrations/035_resident_smash_egg_newbie_pool.sql b/migrations/035_resident_smash_egg_newbie_pool.sql new file mode 100644 index 0000000..0cf7f7e --- /dev/null +++ b/migrations/035_resident_smash_egg_newbie_pool.sql @@ -0,0 +1,121 @@ +SET @current_schema := DATABASE(); + +SET @add_newbie_pool_enabled_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_config' + AND column_name = 'newbie_pool_enabled' + ), + 'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_pool_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''是否启用新人奖池'' AFTER `rtp_basis_points`', + 'SELECT 1' +); +PREPARE add_newbie_pool_enabled_stmt FROM @add_newbie_pool_enabled_sql; +EXECUTE add_newbie_pool_enabled_stmt; +DEALLOCATE PREPARE add_newbie_pool_enabled_stmt; + +SET @add_newbie_window_days_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_config' + AND column_name = 'newbie_window_days' + ), + 'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_window_days` int NOT NULL DEFAULT 7 COMMENT ''新人资格窗口天数'' AFTER `newbie_pool_enabled`', + 'SELECT 1' +); +PREPARE add_newbie_window_days_stmt FROM @add_newbie_window_days_sql; +EXECUTE add_newbie_window_days_stmt; +DEALLOCATE PREPARE add_newbie_window_days_stmt; + +SET @add_newbie_max_draw_count_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_config' + AND column_name = 'newbie_max_draw_count' + ), + 'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_max_draw_count` int NOT NULL DEFAULT 3 COMMENT ''每个用户最多使用新人奖池的抽奖请求次数'' AFTER `newbie_window_days`', + 'SELECT 1' +); +PREPARE add_newbie_max_draw_count_stmt FROM @add_newbie_max_draw_count_sql; +EXECUTE add_newbie_max_draw_count_stmt; +DEALLOCATE PREPARE add_newbie_max_draw_count_stmt; + +SET @add_newbie_min_recharge_amount_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_config' + AND column_name = 'newbie_min_recharge_amount' + ), + 'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_min_recharge_amount` bigint NOT NULL DEFAULT 0 COMMENT ''新人奖池最低累计充值金额,0表示不限制'' AFTER `newbie_max_draw_count`', + 'SELECT 1' +); +PREPARE add_newbie_min_recharge_amount_stmt FROM @add_newbie_min_recharge_amount_sql; +EXECUTE add_newbie_min_recharge_amount_stmt; +DEALLOCATE PREPARE add_newbie_min_recharge_amount_stmt; + +SET @add_reward_pool_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_reward_config' + AND column_name = 'pool_type' + ), + 'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `pool_type` varchar(32) NOT NULL DEFAULT ''GENERAL'' COMMENT ''奖池类型 GENERAL/NEWBIE'' AFTER `sys_origin`', + 'SELECT 1' +); +PREPARE add_reward_pool_type_stmt FROM @add_reward_pool_type_sql; +EXECUTE add_reward_pool_type_stmt; +DEALLOCATE PREPARE add_reward_pool_type_stmt; + +SET @add_draw_record_pool_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_draw_record' + AND column_name = 'pool_type' + ), + 'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `pool_type` varchar(32) NOT NULL DEFAULT ''GENERAL'' COMMENT ''奖池类型 GENERAL/NEWBIE'' AFTER `user_id`', + 'SELECT 1' +); +PREPARE add_draw_record_pool_type_stmt FROM @add_draw_record_pool_type_sql; +EXECUTE add_draw_record_pool_type_stmt; +DEALLOCATE PREPARE add_draw_record_pool_type_stmt; + +SET @add_reward_pool_index_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_reward_config' + AND index_name = 'idx_smash_egg_reward_pool' + ), + 'ALTER TABLE `smash_egg_reward_config` ADD KEY `idx_smash_egg_reward_pool` (`config_id`, `pool_type`, `enabled`, `sort`)', + 'SELECT 1' +); +PREPARE add_reward_pool_index_stmt FROM @add_reward_pool_index_sql; +EXECUTE add_reward_pool_index_stmt; +DEALLOCATE PREPARE add_reward_pool_index_stmt; + +SET @add_draw_user_pool_index_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_draw_record' + AND index_name = 'idx_smash_egg_draw_record_user_pool' + ), + 'ALTER TABLE `smash_egg_draw_record` ADD KEY `idx_smash_egg_draw_record_user_pool` (`sys_origin`, `user_id`, `pool_type`, `status`, `create_time`)', + 'SELECT 1' +); +PREPARE add_draw_user_pool_index_stmt FROM @add_draw_user_pool_index_sql; +EXECUTE add_draw_user_pool_index_stmt; +DEALLOCATE PREPARE add_draw_user_pool_index_stmt; diff --git a/migrations/036_resident_smash_egg_resource_rewards.sql b/migrations/036_resident_smash_egg_resource_rewards.sql new file mode 100644 index 0000000..d9139b5 --- /dev/null +++ b/migrations/036_resident_smash_egg_resource_rewards.sql @@ -0,0 +1,91 @@ +SET @current_schema := DATABASE(); + +SET @add_reward_resource_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_reward_config' + AND column_name = 'resource_type' + ), + 'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型'' AFTER `reward_group_name`', + 'SELECT 1' +); +PREPARE add_reward_resource_type_stmt FROM @add_reward_resource_type_sql; +EXECUTE add_reward_resource_type_stmt; +DEALLOCATE PREPARE add_reward_resource_type_stmt; + +SET @add_reward_resource_url_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_reward_config' + AND column_name = 'resource_url' + ), + 'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效/源文件URL'' AFTER `resource_type`', + 'SELECT 1' +); +PREPARE add_reward_resource_url_stmt FROM @add_reward_resource_url_sql; +EXECUTE add_reward_resource_url_stmt; +DEALLOCATE PREPARE add_reward_resource_url_stmt; + +SET @add_reward_display_gold_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_reward_config' + AND column_name = 'display_gold_amount' + ), + 'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源前端展示金币价格'' AFTER `duration_days`', + 'SELECT 1' +); +PREPARE add_reward_display_gold_stmt FROM @add_reward_display_gold_sql; +EXECUTE add_reward_display_gold_stmt; +DEALLOCATE PREPARE add_reward_display_gold_stmt; + +SET @add_record_resource_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_draw_record' + AND column_name = 'resource_type' + ), + 'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型快照'' AFTER `reward_group_name`', + 'SELECT 1' +); +PREPARE add_record_resource_type_stmt FROM @add_record_resource_type_sql; +EXECUTE add_record_resource_type_stmt; +DEALLOCATE PREPARE add_record_resource_type_stmt; + +SET @add_record_resource_url_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_draw_record' + AND column_name = 'resource_url' + ), + 'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效/源文件URL快照'' AFTER `resource_type`', + 'SELECT 1' +); +PREPARE add_record_resource_url_stmt FROM @add_record_resource_url_sql; +EXECUTE add_record_resource_url_stmt; +DEALLOCATE PREPARE add_record_resource_url_stmt; + +SET @add_record_display_gold_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'smash_egg_draw_record' + AND column_name = 'display_gold_amount' + ), + 'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源前端展示金币价格快照'' AFTER `duration_days`', + 'SELECT 1' +); +PREPARE add_record_display_gold_stmt FROM @add_record_display_gold_sql; +EXECUTE add_record_display_gold_stmt; +DEALLOCATE PREPARE add_record_display_gold_stmt; diff --git a/migrations/036_wheel_resource_reward.sql b/migrations/036_wheel_resource_reward.sql new file mode 100644 index 0000000..09f4f7e --- /dev/null +++ b/migrations/036_wheel_resource_reward.sql @@ -0,0 +1,189 @@ +SET @current_schema := DATABASE(); + +SET @add_wheel_reward_resource_id_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'resource_id' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_id` bigint DEFAULT NULL COMMENT ''资源ID'' AFTER `reward_type`', + 'SELECT 1' +); +PREPARE add_wheel_reward_resource_id_stmt FROM @add_wheel_reward_resource_id_sql; +EXECUTE add_wheel_reward_resource_id_stmt; +DEALLOCATE PREPARE add_wheel_reward_resource_id_stmt; + +SET @add_wheel_reward_resource_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'resource_type' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型'' AFTER `resource_id`', + 'SELECT 1' +); +PREPARE add_wheel_reward_resource_type_stmt FROM @add_wheel_reward_resource_type_sql; +EXECUTE add_wheel_reward_resource_type_stmt; +DEALLOCATE PREPARE add_wheel_reward_resource_type_stmt; + +SET @add_wheel_reward_resource_name_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'resource_name' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_name` varchar(255) NOT NULL DEFAULT '''' COMMENT ''资源名称快照'' AFTER `resource_type`', + 'SELECT 1' +); +PREPARE add_wheel_reward_resource_name_stmt FROM @add_wheel_reward_resource_name_sql; +EXECUTE add_wheel_reward_resource_name_stmt; +DEALLOCATE PREPARE add_wheel_reward_resource_name_stmt; + +SET @add_wheel_reward_resource_url_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'resource_url' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效URL快照'' AFTER `resource_name`', + 'SELECT 1' +); +PREPARE add_wheel_reward_resource_url_stmt FROM @add_wheel_reward_resource_url_sql; +EXECUTE add_wheel_reward_resource_url_stmt; +DEALLOCATE PREPARE add_wheel_reward_resource_url_stmt; + +SET @add_wheel_reward_duration_days_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'duration_days' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `duration_days` int NOT NULL DEFAULT 0 COMMENT ''资源有效天数'' AFTER `cover_url`', + 'SELECT 1' +); +PREPARE add_wheel_reward_duration_days_stmt FROM @add_wheel_reward_duration_days_sql; +EXECUTE add_wheel_reward_duration_days_stmt; +DEALLOCATE PREPARE add_wheel_reward_duration_days_stmt; + +SET @add_wheel_reward_display_gold_amount_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_reward_config' + AND column_name = 'display_gold_amount' + ), + 'ALTER TABLE `wheel_reward_config` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源奖励前端展示金币价格'' AFTER `duration_days`', + 'SELECT 1' +); +PREPARE add_wheel_reward_display_gold_amount_stmt FROM @add_wheel_reward_display_gold_amount_sql; +EXECUTE add_wheel_reward_display_gold_amount_stmt; +DEALLOCATE PREPARE add_wheel_reward_display_gold_amount_stmt; + +SET @add_wheel_draw_resource_id_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'resource_id' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_id` bigint DEFAULT NULL COMMENT ''资源ID快照'' AFTER `reward_type`', + 'SELECT 1' +); +PREPARE add_wheel_draw_resource_id_stmt FROM @add_wheel_draw_resource_id_sql; +EXECUTE add_wheel_draw_resource_id_stmt; +DEALLOCATE PREPARE add_wheel_draw_resource_id_stmt; + +SET @add_wheel_draw_resource_type_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'resource_type' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型快照'' AFTER `resource_id`', + 'SELECT 1' +); +PREPARE add_wheel_draw_resource_type_stmt FROM @add_wheel_draw_resource_type_sql; +EXECUTE add_wheel_draw_resource_type_stmt; +DEALLOCATE PREPARE add_wheel_draw_resource_type_stmt; + +SET @add_wheel_draw_resource_name_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'resource_name' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_name` varchar(255) NOT NULL DEFAULT '''' COMMENT ''资源名称快照'' AFTER `resource_type`', + 'SELECT 1' +); +PREPARE add_wheel_draw_resource_name_stmt FROM @add_wheel_draw_resource_name_sql; +EXECUTE add_wheel_draw_resource_name_stmt; +DEALLOCATE PREPARE add_wheel_draw_resource_name_stmt; + +SET @add_wheel_draw_resource_url_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'resource_url' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效URL快照'' AFTER `resource_name`', + 'SELECT 1' +); +PREPARE add_wheel_draw_resource_url_stmt FROM @add_wheel_draw_resource_url_sql; +EXECUTE add_wheel_draw_resource_url_stmt; +DEALLOCATE PREPARE add_wheel_draw_resource_url_stmt; + +SET @add_wheel_draw_duration_days_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'duration_days' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `duration_days` int NOT NULL DEFAULT 0 COMMENT ''资源有效天数快照'' AFTER `cover_url`', + 'SELECT 1' +); +PREPARE add_wheel_draw_duration_days_stmt FROM @add_wheel_draw_duration_days_sql; +EXECUTE add_wheel_draw_duration_days_stmt; +DEALLOCATE PREPARE add_wheel_draw_duration_days_stmt; + +SET @add_wheel_draw_display_gold_amount_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'wheel_draw_record' + AND column_name = 'display_gold_amount' + ), + 'ALTER TABLE `wheel_draw_record` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源奖励前端展示金币价格快照'' AFTER `duration_days`', + 'SELECT 1' +); +PREPARE add_wheel_draw_display_gold_amount_stmt FROM @add_wheel_draw_display_gold_amount_sql; +EXECUTE add_wheel_draw_display_gold_amount_stmt; +DEALLOCATE PREPARE add_wheel_draw_display_gold_amount_stmt; + +UPDATE `wheel_reward_config` +SET `reward_type` = 'RESOURCE' +WHERE `reward_type` = 'RESOURCE_GROUP'; + +UPDATE `wheel_draw_record` +SET `reward_type` = 'RESOURCE' +WHERE `reward_type` = 'RESOURCE_GROUP';