砸蛋,转盘
This commit is contained in:
parent
51b8b52c57
commit
31433b0a1b
@ -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,
|
||||
|
||||
98
internal/model/smash_egg_models.go
Normal file
98
internal/model/smash_egg_models.go
Normal file
@ -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" }
|
||||
88
internal/model/wheel_models.go
Normal file
88
internal/model/wheel_models.go
Normal file
@ -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" }
|
||||
@ -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")
|
||||
|
||||
|
||||
243
internal/router/smash_egg_routes.go
Normal file
243
internal/router/smash_egg_routes.go
Normal file
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
389
internal/router/wheel_routes.go
Normal file
389
internal/router/wheel_routes.go
Normal file
@ -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"
|
||||
}
|
||||
624
internal/service/smashegg/config.go
Normal file
624
internal/service/smashegg/config.go
Normal file
@ -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
|
||||
}
|
||||
186
internal/service/smashegg/config_test.go
Normal file
186
internal/service/smashegg/config_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
392
internal/service/smashegg/draw.go
Normal file
392
internal/service/smashegg/draw.go
Normal file
@ -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)
|
||||
}
|
||||
234
internal/service/smashegg/flow_integration_test.go
Normal file
234
internal/service/smashegg/flow_integration_test.go
Normal file
@ -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
|
||||
}
|
||||
273
internal/service/smashegg/legacy.go
Normal file
273
internal/service/smashegg/legacy.go
Normal file
@ -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
|
||||
}
|
||||
123
internal/service/smashegg/newbie.go
Normal file
123
internal/service/smashegg/newbie.go
Normal file
@ -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
|
||||
}
|
||||
243
internal/service/smashegg/probability.go
Normal file
243
internal/service/smashegg/probability.go
Normal file
@ -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
|
||||
}
|
||||
299
internal/service/smashegg/records.go
Normal file
299
internal/service/smashegg/records.go
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
103
internal/service/smashegg/records_test.go
Normal file
103
internal/service/smashegg/records_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
122
internal/service/smashegg/simulation_test.go
Normal file
122
internal/service/smashegg/simulation_test.go
Normal file
@ -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
|
||||
}
|
||||
483
internal/service/smashegg/types.go
Normal file
483
internal/service/smashegg/types.go
Normal file
@ -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)
|
||||
}
|
||||
623
internal/service/wheel/config.go
Normal file
623
internal/service/wheel/config.go
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
173
internal/service/wheel/config_test.go
Normal file
173
internal/service/wheel/config_test.go
Normal file
@ -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
|
||||
}
|
||||
461
internal/service/wheel/draw.go
Normal file
461
internal/service/wheel/draw.go
Normal file
@ -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)
|
||||
}
|
||||
58
internal/service/wheel/probability_test.go
Normal file
58
internal/service/wheel/probability_test.go
Normal file
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
156
internal/service/wheel/records.go
Normal file
156
internal/service/wheel/records.go
Normal file
@ -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
|
||||
}
|
||||
429
internal/service/wheel/types.go
Normal file
429
internal/service/wheel/types.go
Normal file
@ -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)
|
||||
}
|
||||
82
migrations/033_resident_wheel.sql
Normal file
82
migrations/033_resident_wheel.sql
Normal file
@ -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='常驻活动转盘抽奖记录';
|
||||
81
migrations/034_resident_smash_egg.sql
Normal file
81
migrations/034_resident_smash_egg.sql
Normal file
@ -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='常驻活动砸蛋抽奖记录';
|
||||
121
migrations/035_resident_smash_egg_newbie_pool.sql
Normal file
121
migrations/035_resident_smash_egg_newbie_pool.sql
Normal file
@ -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;
|
||||
91
migrations/036_resident_smash_egg_resource_rewards.sql
Normal file
91
migrations/036_resident_smash_egg_resource_rewards.sql
Normal file
@ -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;
|
||||
189
migrations/036_wheel_resource_reward.sql
Normal file
189
migrations/036_wheel_resource_reward.sql
Normal file
@ -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';
|
||||
Loading…
x
Reference in New Issue
Block a user