砸蛋,转盘

This commit is contained in:
hy001 2026-05-15 09:58:01 +08:00
parent 51b8b52c57
commit 31433b0a1b
28 changed files with 6377 additions and 1 deletions

View File

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

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

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

View File

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

View 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,
},
})
}

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

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

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

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

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

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

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

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

View 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),
}
}

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

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

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

View 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),
}
}

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

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

View 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,
)
}
}
}

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

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

View 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='常驻活动转盘抽奖记录';

View 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='常驻活动砸蛋抽奖记录';

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

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

View 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';