feat(game-king): support direct reward resources
This commit is contained in:
parent
a9f9bd5275
commit
82f35f2f91
@ -772,7 +772,7 @@ func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardR
|
|||||||
|
|
||||||
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
|
// SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。
|
||||||
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
func (c *Client) SendFrozenActivityReward(ctx context.Context, req SendFrozenActivityRewardRequest) error {
|
||||||
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) != 1 {
|
if req.TrackID <= 0 || req.AcceptUserID <= 0 || len(req.Prizes) == 0 {
|
||||||
return fmt.Errorf("invalid frozen activity reward request")
|
return fmt.Errorf("invalid frozen activity reward request")
|
||||||
}
|
}
|
||||||
var resp resultResponse[any]
|
var resp resultResponse[any]
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type YumiGameKingActivity struct {
|
|||||||
RankingType string `gorm:"column:ranking_type;size:32"`
|
RankingType string `gorm:"column:ranking_type;size:32"`
|
||||||
RankingPeriod string `gorm:"column:ranking_period;size:32"`
|
RankingPeriod string `gorm:"column:ranking_period;size:32"`
|
||||||
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"`
|
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"`
|
||||||
|
EverEnabled bool `gorm:"column:ever_enabled"`
|
||||||
SettlementStatus string `gorm:"column:settlement_status;size:32;index:idx_yumi_gk_activity_settle,priority:1"`
|
SettlementStatus string `gorm:"column:settlement_status;size:32;index:idx_yumi_gk_activity_settle,priority:1"`
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
@ -197,7 +198,7 @@ func (YumiGameKingSettlementRecord) TableName() string {
|
|||||||
return "yumi_game_king_settlement_record"
|
return "yumi_game_king_settlement_record"
|
||||||
}
|
}
|
||||||
|
|
||||||
// YumiGameKingDeliveryItem 把一次奖励组调用作为最小核账单元;UNKNOWN 不允许自动重试。
|
// YumiGameKingDeliveryItem 冻结一次中奖或结算对应的直接资源列表;UNKNOWN 不允许自动重试。
|
||||||
type YumiGameKingDeliveryItem struct {
|
type YumiGameKingDeliveryItem struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;index:idx_yumi_gk_delivery_status,priority:3"`
|
ID int64 `gorm:"column:id;primaryKey;index:idx_yumi_gk_delivery_status,priority:3"`
|
||||||
OwnerType string `gorm:"column:owner_type;size:20;uniqueIndex:uk_yumi_gk_delivery_owner,priority:1"`
|
OwnerType string `gorm:"column:owner_type;size:20;uniqueIndex:uk_yumi_gk_delivery_owner,priority:1"`
|
||||||
@ -205,6 +206,7 @@ type YumiGameKingDeliveryItem struct {
|
|||||||
ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"`
|
ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"`
|
||||||
UserID int64 `gorm:"column:user_id"`
|
UserID int64 `gorm:"column:user_id"`
|
||||||
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
ResourceGroupID int64 `gorm:"column:resource_group_id"`
|
||||||
|
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
|
||||||
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_delivery_status,priority:1;index:idx_yumi_gk_delivery_activity,priority:2"`
|
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_delivery_status,priority:1;index:idx_yumi_gk_delivery_activity,priority:2"`
|
||||||
RetryCount int `gorm:"column:retry_count"`
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
FailureReason string `gorm:"column:failure_reason;size:500"`
|
FailureReason string `gorm:"column:failure_reason;size:500"`
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package gameking
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -73,7 +74,8 @@ func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingAc
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。
|
// SaveActivity 新增或修改活动。统计口径开始后保持不可变,直接资源奖励和启停可继续维护;
|
||||||
|
// 已产生的中奖/结算记录会使用各自 delivery item 内的冻结快照。
|
||||||
func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) {
|
func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) {
|
||||||
normalized, err := normalizeActivityInput(req)
|
normalized, err := normalizeActivityInput(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -87,6 +89,9 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
|
|||||||
return nil, loadErr
|
return nil, loadErr
|
||||||
}
|
}
|
||||||
existing = &row
|
existing = &row
|
||||||
|
// 编辑配置不隐式携带启停意图;活动状态只能走独立 enable 接口,避免旧表单保存
|
||||||
|
// 把另一位运营刚执行的启停覆盖掉。
|
||||||
|
normalized.Enabled = row.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedPrizes []normalizedPrize
|
var normalizedPrizes []normalizedPrize
|
||||||
@ -151,13 +156,10 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
|
|||||||
// 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后
|
// 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后
|
||||||
// 重新取时钟,不能用事务外的旧 now 绕过开始锁。
|
// 重新取时钟,不能用事务外的旧 now 绕过开始锁。
|
||||||
writeTime = time.Now()
|
writeTime = time.Now()
|
||||||
if row.Enabled && !writeTime.Before(row.StartTime) {
|
if row.EverEnabled && !writeTime.Before(row.StartTime) {
|
||||||
if err := validateStartedActivityUpdate(row, normalized); err != nil {
|
if err := validateStartedActivityUpdate(row, normalized); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if normalizedPrizes != nil || normalizedRanks != nil {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes and rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
activityID, err = utils.NextID()
|
activityID, err = utils.NextID()
|
||||||
@ -172,9 +174,6 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
|
|||||||
return NewAppError(http.StatusConflict, "activity_state_changed", "activity state changed; reload and retry")
|
return NewAppError(http.StatusConflict, "activity_state_changed", "activity state changed; reload and retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized.Enabled && !row.Enabled && !time.UnixMilli(normalized.StartTime).After(writeTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
|
||||||
}
|
|
||||||
// 已启用但尚未开始的活动仍可能修改时间窗;只要本次保存结果为 enabled,
|
// 已启用但尚未开始的活动仍可能修改时间窗;只要本次保存结果为 enabled,
|
||||||
// 就与 GC 删除事务锁同一控制行,避免 GC 按旧窗口保护后活动扩展到已删除区间。
|
// 就与 GC 删除事务锁同一控制行,避免 GC 按旧窗口保护后活动扩展到已删除区间。
|
||||||
if normalized.Enabled {
|
if normalized.Enabled {
|
||||||
@ -214,6 +213,9 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
|
|||||||
row.RankingType = normalized.RankingType
|
row.RankingType = normalized.RankingType
|
||||||
row.RankingPeriod = normalized.RankingPeriod
|
row.RankingPeriod = normalized.RankingPeriod
|
||||||
row.Enabled = normalized.Enabled
|
row.Enabled = normalized.Enabled
|
||||||
|
if normalized.Enabled {
|
||||||
|
row.EverEnabled = true
|
||||||
|
}
|
||||||
row.UpdateTime = writeTime
|
row.UpdateTime = writeTime
|
||||||
if row.SettlementStatus == "" {
|
if row.SettlementStatus == "" {
|
||||||
row.SettlementStatus = SettlementNotStarted
|
row.SettlementStatus = SettlementNotStarted
|
||||||
@ -244,15 +246,13 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
|
|||||||
return &view, nil
|
return &view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。
|
// EnableActivity 可在活动生命周期内随时启停;关闭只阻止新入账和抽奖,
|
||||||
|
// 已经产生的数据仍由 ever_enabled 驱动日榜和总榜结算。
|
||||||
func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error {
|
func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error {
|
||||||
activity, err := s.loadActivity(ctx, activityID)
|
activity, err := s.loadActivity(ctx, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
|
||||||
}
|
|
||||||
var prizes []normalizedPrize
|
var prizes []normalizedPrize
|
||||||
var ranks []normalizedRankReward
|
var ranks []normalizedRankReward
|
||||||
if enabled {
|
if enabled {
|
||||||
@ -279,16 +279,10 @@ func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
lockedNow := time.Now()
|
lockedNow := time.Now()
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity enable state cannot change after activity start")
|
|
||||||
}
|
|
||||||
if enabled {
|
if enabled {
|
||||||
if err := ensureActivityEnableSafetyTx(tx); err != nil {
|
if err := ensureActivityEnableSafetyTx(tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !locked.StartTime.After(lockedNow) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_start_must_be_future", "the first enabled startTime must be in the future")
|
|
||||||
}
|
|
||||||
if err := lockActivityScopeTx(tx, locked.SysOrigin, lockedNow); err != nil {
|
if err := lockActivityScopeTx(tx, locked.SysOrigin, lockedNow); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -302,8 +296,11 @@ func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
|
updates := map[string]any{"enabled": enabled, "update_time": lockedNow}
|
||||||
Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error
|
if enabled {
|
||||||
|
updates["ever_enabled"] = true
|
||||||
|
}
|
||||||
|
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).Updates(updates).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,9 +327,6 @@ func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePriz
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
|
||||||
}
|
|
||||||
values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes)
|
values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -342,11 +336,7 @@ func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePriz
|
|||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
lockedNow := time.Now()
|
return replacePrizesTx(tx, activityID, values, time.Now())
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "prizes cannot change after activity start")
|
|
||||||
}
|
|
||||||
return replacePrizesTx(tx, activityID, values, lockedNow)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,9 +363,6 @@ func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req Sav
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if activity.Enabled && !time.Now().Before(activity.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards)
|
values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -385,11 +372,7 @@ func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req Sav
|
|||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
lockedNow := time.Now()
|
return replaceRankRewardsTx(tx, activityID, values, time.Now())
|
||||||
if locked.Enabled && !lockedNow.Before(locked.StartTime) {
|
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "rank rewards cannot change after activity start")
|
|
||||||
}
|
|
||||||
return replaceRankRewardsTx(tx, activityID, values, lockedNow)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,7 +435,11 @@ func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]P
|
|||||||
}
|
}
|
||||||
values := make([]PrizeInput, 0, len(rows))
|
values := make([]PrizeInput, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
values = append(values, PrizeInput{ID: FlexibleInt64(row.ID), PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, Weight: row.Weight, Stock: row.Stock, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), Enabled: row.Enabled, SortOrder: row.SortOrder})
|
values = append(values, PrizeInput{
|
||||||
|
ID: FlexibleInt64(row.ID), PrizeName: row.PrizeName, PrizeImage: row.PrizeImage,
|
||||||
|
Weight: row.Weight, Stock: row.Stock, ResourceGroupID: FlexibleInt64(row.ResourceGroupID),
|
||||||
|
RewardItems: rewardItemInputs(row.RewardItemsJSON), Enabled: row.Enabled, SortOrder: row.SortOrder,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
@ -464,48 +451,82 @@ func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]Ra
|
|||||||
}
|
}
|
||||||
values := make([]RankRewardInput, 0, len(rows))
|
values := make([]RankRewardInput, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
|
values = append(values, RankRewardInput{
|
||||||
|
ID: FlexibleInt64(row.ID), RankingType: row.RankingType, RankingPeriod: row.RankingPeriod,
|
||||||
|
StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID),
|
||||||
|
RewardItems: rewardItemInputs(row.RewardItemsJSON), RewardName: row.RewardName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replacePrizesTx(tx *gorm.DB, activityID int64, values []normalizedPrize, now time.Time) error {
|
func replacePrizesTx(tx *gorm.DB, activityID int64, values []normalizedPrize, now time.Time) error {
|
||||||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingPrize{}).Error; err != nil {
|
var activity model.YumiGameKingActivity
|
||||||
|
if err := tx.Select("id", "start_time", "ever_enabled").Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows := make([]model.YumiGameKingPrize, 0, len(values))
|
preserveConsumedStock := activity.EverEnabled && !now.Before(activity.StartTime)
|
||||||
|
var existing []model.YumiGameKingPrize
|
||||||
|
if err := tx.Where("activity_id = ?", activityID).Find(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bySort := make(map[int]model.YumiGameKingPrize, len(existing))
|
||||||
|
for _, row := range existing {
|
||||||
|
bySort[row.SortOrder] = row
|
||||||
|
}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
id, err := utils.NextID()
|
row, exists := bySort[value.Input.SortOrder]
|
||||||
if err != nil {
|
if !exists {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row = model.YumiGameKingPrize{ID: id, ActivityID: activityID, CreateTime: now}
|
||||||
|
}
|
||||||
|
row.PrizeName, row.PrizeImage = value.Input.PrizeName, value.Input.PrizeImage
|
||||||
|
row.Weight, row.ResourceGroupID = value.Input.Weight, value.Snapshot.GroupID
|
||||||
|
// 活动开始后库存可能正被并发抽奖扣减;管理页的旧快照不能把已消耗库存写回来。
|
||||||
|
if !exists || !preserveConsumedStock {
|
||||||
|
row.Stock = value.Input.Stock
|
||||||
|
}
|
||||||
|
row.RewardGroupName, row.RewardItemsJSON = value.Snapshot.GroupName, value.Snapshot.ItemsJSON
|
||||||
|
row.Enabled, row.SortOrder, row.UpdateTime = value.Input.Enabled, value.Input.SortOrder, now
|
||||||
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows = append(rows, model.YumiGameKingPrize{
|
|
||||||
ID: id, ActivityID: activityID, PrizeName: value.Input.PrizeName, PrizeImage: value.Input.PrizeImage,
|
|
||||||
Weight: value.Input.Weight, Stock: value.Input.Stock, ResourceGroupID: value.Snapshot.GroupID,
|
|
||||||
RewardGroupName: value.Snapshot.GroupName, RewardItemsJSON: value.Snapshot.ItemsJSON,
|
|
||||||
Enabled: true, SortOrder: value.Input.SortOrder, CreateTime: now, UpdateTime: now,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return tx.Create(&rows).Error
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
|
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
|
||||||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGameKingRankReward{}).Error; err != nil {
|
var existing []model.YumiGameKingRankReward
|
||||||
|
if err := tx.Where("activity_id = ?", activityID).Find(&existing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows := make([]model.YumiGameKingRankReward, 0, len(values))
|
byRange := make(map[string]model.YumiGameKingRankReward, len(existing))
|
||||||
|
for _, row := range existing {
|
||||||
|
key := fmt.Sprintf("%s:%s:%d:%d", row.RankingPeriod, row.RankingType, row.StartRank, row.EndRank)
|
||||||
|
byRange[key] = row
|
||||||
|
}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
id, err := utils.NextID()
|
key := fmt.Sprintf("%s:%s:%d:%d", value.Input.RankingPeriod, value.Input.RankingType, value.Input.StartRank, value.Input.EndRank)
|
||||||
if err != nil {
|
row, exists := byRange[key]
|
||||||
|
if !exists {
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row = model.YumiGameKingRankReward{ID: id, ActivityID: activityID, CreateTime: now}
|
||||||
|
}
|
||||||
|
row.RankingType, row.RankingPeriod = value.Input.RankingType, value.Input.RankingPeriod
|
||||||
|
row.StartRank, row.EndRank = value.Input.StartRank, value.Input.EndRank
|
||||||
|
row.ResourceGroupID, row.RewardName = value.Snapshot.GroupID, value.Input.RewardName
|
||||||
|
row.RewardItemsJSON, row.UpdateTime = value.Snapshot.ItemsJSON, now
|
||||||
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows = append(rows, model.YumiGameKingRankReward{
|
|
||||||
ID: id, ActivityID: activityID, RankingType: value.Input.RankingType, RankingPeriod: value.Input.RankingPeriod, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
|
|
||||||
ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
|
|
||||||
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return tx.Create(&rows).Error
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error {
|
func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error {
|
||||||
@ -539,8 +560,21 @@ func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActiv
|
|||||||
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
|
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
|
||||||
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
|
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
|
||||||
row.DailyChanceLimit != req.DailyChanceLimit ||
|
row.DailyChanceLimit != req.DailyChanceLimit ||
|
||||||
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled {
|
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod {
|
||||||
return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start")
|
return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time and chance rule cannot change after activity start")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rewardItemInputs(raw string) []RewardItemInput {
|
||||||
|
items := rewardItems(raw)
|
||||||
|
result := make([]RewardItemInput, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
result = append(result, RewardItemInput{
|
||||||
|
ID: FlexibleInt64(item.ID), Type: item.Type, DetailType: item.DetailType,
|
||||||
|
Content: FlexibleString(item.Content), Quantity: item.Quantity, Cover: item.Cover,
|
||||||
|
SourceURL: item.SourceURL, Name: item.Name, Remark: item.Remark, Sort: item.Sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ type frozenDeliveryContext struct {
|
|||||||
RewardItems []RewardItemView
|
RewardItems []RewardItemView
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。
|
// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 直接资源快照并调用 Java。
|
||||||
// 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。
|
// 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。
|
||||||
func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error {
|
func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error {
|
||||||
// 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下
|
// 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下
|
||||||
@ -86,28 +86,42 @@ func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry
|
|||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current, err := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
// 奖励组查询发生在真正发奖之前,因此网络失败、下架、跨租户或不存在都能明确
|
|
||||||
// 判定“未调用发奖”,记录 FAILED,待配置恢复后由运营显式 retry。
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if current.ItemsJSON != frozen.RewardItemsJSON {
|
|
||||||
err = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "reward group content changed after activity enable; restore it before retry")
|
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if s.gateway == nil {
|
if s.gateway == nil {
|
||||||
err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
||||||
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
if strings.TrimSpace(item.RewardItemsJSON) == "" && item.ResourceGroupID > 0 {
|
||||||
TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin,
|
// 迁移前已创建的 delivery item 沿用资源组发奖,避免发布过程中中断旧订单。
|
||||||
SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID,
|
current, lookupErr := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID)
|
||||||
})
|
if lookupErr != nil {
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, lookupErr.Error())
|
||||||
|
return lookupErr
|
||||||
|
}
|
||||||
|
if current.ItemsJSON != frozen.RewardItemsJSON {
|
||||||
|
lookupErr = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "legacy reward group content changed; restore it before retry")
|
||||||
|
_ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, lookupErr.Error())
|
||||||
|
return lookupErr
|
||||||
|
}
|
||||||
|
err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
||||||
|
TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin,
|
||||||
|
SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
prizes := make([]integration.FrozenActivityReward, 0, len(frozen.RewardItems))
|
||||||
|
for _, reward := range frozen.RewardItems {
|
||||||
|
prizes = append(prizes, integration.FrozenActivityReward{
|
||||||
|
// Java 内层 trackId 仅用于奖励明细追踪;外层 delivery item ID 才是整单幂等号。
|
||||||
|
TrackID: item.ID, Type: reward.Type, DetailType: reward.DetailType,
|
||||||
|
Content: reward.Content, Quantity: reward.Quantity, Remark: reward.Remark,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err = s.gateway.SendFrozenActivityReward(ctx, integration.SendFrozenActivityRewardRequest{
|
||||||
|
TrackID: item.ID, AcceptUserID: item.UserID, Origin: rewardOriginGameKing,
|
||||||
|
SysOrigin: frozen.SysOrigin, Prizes: prizes,
|
||||||
|
})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN,
|
// HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN,
|
||||||
// 禁止自动或普通 retry,必须用 trackId/businessNo 核账后人工 resolve。
|
// 禁止自动或普通 retry,必须用 trackId/businessNo 核账后人工 resolve。
|
||||||
@ -212,42 +226,47 @@ func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.Yumi
|
|||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil {
|
||||||
return frozenDeliveryContext{}, err
|
return frozenDeliveryContext{}, err
|
||||||
}
|
}
|
||||||
result := frozenDeliveryContext{SysOrigin: activity.SysOrigin}
|
result := frozenDeliveryContext{SysOrigin: activity.SysOrigin, RewardItemsJSON: item.RewardItemsJSON}
|
||||||
switch item.OwnerType {
|
switch item.OwnerType {
|
||||||
case OwnerDraw:
|
case OwnerDraw:
|
||||||
var record model.YumiGameKingDrawRecord
|
var record model.YumiGameKingDrawRecord
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
var prize model.YumiGameKingPrize
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if prize.ResourceGroupID != item.ResourceGroupID {
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "draw reward group does not match frozen prize")
|
|
||||||
}
|
|
||||||
result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID)
|
result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID)
|
||||||
result.RewardItemsJSON = prize.RewardItemsJSON
|
if strings.TrimSpace(result.RewardItemsJSON) == "" {
|
||||||
result.RewardItems = rewardItems(prize.RewardItemsJSON)
|
var prize model.YumiGameKingPrize
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if prize.ResourceGroupID != item.ResourceGroupID {
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "legacy draw reward group does not match frozen prize")
|
||||||
|
}
|
||||||
|
result.RewardItemsJSON = prize.RewardItemsJSON
|
||||||
|
}
|
||||||
case OwnerSettlement:
|
case OwnerSettlement:
|
||||||
var record model.YumiGameKingSettlementRecord
|
var record model.YumiGameKingSettlementRecord
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
var rankReward model.YumiGameKingRankReward
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if rankReward.ResourceGroupID != item.ResourceGroupID ||
|
|
||||||
rankReward.RankingPeriod != record.RankingPeriod || rankReward.RankingType != record.RankingType {
|
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier")
|
|
||||||
}
|
|
||||||
result.BusinessNo = record.BusinessNo
|
result.BusinessNo = record.BusinessNo
|
||||||
result.RewardItemsJSON = rankReward.RewardItemsJSON
|
if strings.TrimSpace(result.RewardItemsJSON) == "" {
|
||||||
result.RewardItems = rewardItems(rankReward.RewardItemsJSON)
|
var rankReward model.YumiGameKingRankReward
|
||||||
|
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if rankReward.ResourceGroupID != item.ResourceGroupID ||
|
||||||
|
rankReward.RankingPeriod != record.RankingPeriod || rankReward.RankingType != record.RankingType {
|
||||||
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "legacy settlement reward group does not match frozen rank tier")
|
||||||
|
}
|
||||||
|
result.RewardItemsJSON = rankReward.RewardItemsJSON
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
|
||||||
|
result.RewardItems = rewardItems(result.RewardItemsJSON)
|
||||||
|
}
|
||||||
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
|
if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 {
|
||||||
return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty")
|
return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,8 +118,9 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
|
|||||||
}
|
}
|
||||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||||
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
|
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
|
||||||
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
|
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, RewardItemsJSON: prize.RewardItemsJSON,
|
||||||
CreateTime: txNow, UpdateTime: txNow,
|
DeliveryStatus: DeliveryPending,
|
||||||
|
CreateTime: txNow, UpdateTime: txNow,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -217,18 +218,8 @@ func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingP
|
|||||||
Order("sort_order ASC").Find(&rows).Error; err != nil {
|
Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||||
return model.YumiGameKingPrize{}, err
|
return model.YumiGameKingPrize{}, err
|
||||||
}
|
}
|
||||||
if len(rows) != 7 {
|
|
||||||
// 配置固定七个,但有限库存耗尽后允许从剩余奖项继续抽;只有配置本身缺失才报错。
|
|
||||||
var configured int64
|
|
||||||
if err := tx.Model(&model.YumiGameKingPrize{}).Where("activity_id = ? AND enabled = ? AND weight > 0", activityID, true).Count(&configured).Error; err != nil {
|
|
||||||
return model.YumiGameKingPrize{}, err
|
|
||||||
}
|
|
||||||
if configured != 7 {
|
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_config_invalid", "seven enabled positive-weight prizes are required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all prize stock is exhausted")
|
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all enabled prize stock is exhausted")
|
||||||
}
|
}
|
||||||
selected, err := weightedPrize(rows)
|
selected, err := weightedPrize(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -126,6 +127,7 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values
|
|||||||
}
|
}
|
||||||
seenSort := make(map[int]struct{}, 7)
|
seenSort := make(map[int]struct{}, 7)
|
||||||
result := make([]normalizedPrize, 0, 7)
|
result := make([]normalizedPrize, 0, 7)
|
||||||
|
enabledCount := 0
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
value.PrizeName = strings.TrimSpace(value.PrizeName)
|
value.PrizeName = strings.TrimSpace(value.PrizeName)
|
||||||
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
|
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
|
||||||
@ -135,8 +137,8 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values
|
|||||||
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
|
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
|
||||||
}
|
}
|
||||||
if !value.Enabled {
|
if value.Enabled {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled")
|
enabledCount++
|
||||||
}
|
}
|
||||||
if value.Weight <= 0 {
|
if value.Weight <= 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
|
||||||
@ -151,15 +153,15 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values
|
|||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
|
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
|
||||||
}
|
}
|
||||||
seenSort[value.SortOrder] = struct{}{}
|
seenSort[value.SortOrder] = struct{}{}
|
||||||
if value.ResourceGroupID.Int64() <= 0 {
|
snapshot, err := s.normalizeConfiguredReward(ctx, sysOrigin, value.ResourceGroupID.Int64(), value.RewardItems)
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group")
|
|
||||||
}
|
|
||||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
|
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
|
||||||
}
|
}
|
||||||
|
if enabledCount == 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "at least one prize must be enabled")
|
||||||
|
}
|
||||||
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
|
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@ -202,11 +204,8 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
|
|||||||
if !exists {
|
if !exists {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "DAILY rewards must be Top1/Top2/Top3 and OVERALL rewards must cover Top30")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "DAILY rewards must be Top1/Top2/Top3 and OVERALL rewards must cover Top30")
|
||||||
}
|
}
|
||||||
if value.ResourceGroupID.Int64() <= 0 {
|
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
|
||||||
}
|
|
||||||
value.RewardName = strings.TrimSpace(value.RewardName)
|
value.RewardName = strings.TrimSpace(value.RewardName)
|
||||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
snapshot, err := s.normalizeConfiguredReward(ctx, sysOrigin, value.ResourceGroupID.Int64(), value.RewardItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -223,6 +222,68 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) normalizeConfiguredReward(ctx context.Context, sysOrigin string, legacyGroupID int64, values []RewardItemInput) (rewardGroupSnapshot, error) {
|
||||||
|
// 旧活动仍可读取资源组并转换为冻结快照;新配置只保存后台直接多选的资源项。
|
||||||
|
if len(values) == 0 && legacyGroupID > 0 {
|
||||||
|
return s.loadRewardGroupSnapshot(ctx, sysOrigin, legacyGroupID)
|
||||||
|
}
|
||||||
|
if len(values) == 0 || len(values) > 100 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "invalid_reward_items", "every reward must contain 1 to 100 direct resource items")
|
||||||
|
}
|
||||||
|
items := make([]RewardItemView, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
item := RewardItemView{
|
||||||
|
ID: value.ID.Int64(),
|
||||||
|
Type: strings.ToUpper(strings.TrimSpace(value.Type)),
|
||||||
|
DetailType: strings.ToUpper(strings.TrimSpace(value.DetailType)),
|
||||||
|
Content: strings.TrimSpace(value.Content.String()),
|
||||||
|
Quantity: value.Quantity,
|
||||||
|
Cover: strings.TrimSpace(value.Cover),
|
||||||
|
SourceURL: strings.TrimSpace(value.SourceURL),
|
||||||
|
Name: strings.TrimSpace(value.Name),
|
||||||
|
Remark: strings.TrimSpace(value.Remark),
|
||||||
|
Sort: index + 1,
|
||||||
|
}
|
||||||
|
if value.Sort > 0 {
|
||||||
|
item.Sort = value.Sort
|
||||||
|
}
|
||||||
|
if item.Type == "" || item.Content == "" || len(item.Content) > 256 ||
|
||||||
|
len(item.DetailType) > 64 || len(item.Cover) > 500 || len(item.SourceURL) > 500 ||
|
||||||
|
len(item.Name) > 128 || len(item.Remark) > 256 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", "direct reward item fields are invalid")
|
||||||
|
}
|
||||||
|
if item.Type == "GOLD" {
|
||||||
|
amount, parseErr := strconv.ParseInt(item.Content, 10, 64)
|
||||||
|
if parseErr != nil || amount <= 0 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "invalid_reward_item_quantity", "gold reward content must be a positive integer")
|
||||||
|
}
|
||||||
|
} else if item.Quantity <= 0 || item.Quantity > math.MaxInt32 {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "invalid_reward_item_quantity", "reward quantity must be between 1 and 2147483647")
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%s:%s:%s", item.Type, item.DetailType, item.Content)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "duplicate_reward_item", "the same direct resource can only be selected once")
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].Sort != items[j].Sort {
|
||||||
|
return items[i].Sort < items[j].Sort
|
||||||
|
}
|
||||||
|
return i < j
|
||||||
|
})
|
||||||
|
for index := range items {
|
||||||
|
items[index].Sort = index + 1
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return rewardGroupSnapshot{}, err
|
||||||
|
}
|
||||||
|
return rewardGroupSnapshot{ItemsJSON: string(raw), Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
|
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
|
||||||
if s.gateway == nil {
|
if s.gateway == nil {
|
||||||
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
||||||
@ -315,6 +376,7 @@ func activityView(row model.YumiGameKingActivity) ActivityView {
|
|||||||
RankingType: row.RankingType,
|
RankingType: row.RankingType,
|
||||||
RankingPeriod: row.RankingPeriod,
|
RankingPeriod: row.RankingPeriod,
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
|
EverEnabled: row.EverEnabled,
|
||||||
SettlementStatus: row.SettlementStatus,
|
SettlementStatus: row.SettlementStatus,
|
||||||
CreateTime: unixMilli(row.CreateTime),
|
CreateTime: unixMilli(row.CreateTime),
|
||||||
UpdateTime: unixMilli(row.UpdateTime),
|
UpdateTime: unixMilli(row.UpdateTime),
|
||||||
|
|||||||
@ -59,8 +59,8 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if !activity.Enabled {
|
if !activity.EverEnabled {
|
||||||
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
|
return NewAppError(http.StatusConflict, "activity_never_enabled", "activity has never been enabled")
|
||||||
}
|
}
|
||||||
if now.Before(activity.SettlementTime) {
|
if now.Before(activity.SettlementTime) {
|
||||||
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
|
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
|
||||||
@ -127,7 +127,8 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
|
|||||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||||
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
||||||
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
||||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
RewardItemsJSON: reward.RewardItemsJSON, DeliveryStatus: DeliveryPending,
|
||||||
|
CreateTime: now, UpdateTime: now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -197,8 +198,8 @@ func (s *Service) freezeDailySettlement(ctx context.Context, activityID int64, s
|
|||||||
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !activity.Enabled {
|
if !activity.EverEnabled {
|
||||||
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
|
return NewAppError(http.StatusConflict, "activity_never_enabled", "activity has never been enabled")
|
||||||
}
|
}
|
||||||
if time.Now().Before(dueTime) {
|
if time.Now().Before(dueTime) {
|
||||||
return NewAppError(http.StatusConflict, "daily_settlement_not_due", "daily settlement time has not arrived")
|
return NewAppError(http.StatusConflict, "daily_settlement_not_due", "daily settlement time has not arrived")
|
||||||
@ -292,7 +293,8 @@ func (s *Service) freezeDailySettlement(ctx context.Context, activityID int64, s
|
|||||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||||
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
||||||
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
||||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
RewardItemsJSON: reward.RewardItemsJSON, DeliveryStatus: DeliveryPending,
|
||||||
|
CreateTime: now, UpdateTime: now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -318,7 +320,7 @@ func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
var dailyActivities []model.YumiGameKingActivity
|
var dailyActivities []model.YumiGameKingActivity
|
||||||
if err := s.db.WithContext(ctx).
|
if err := s.db.WithContext(ctx).
|
||||||
Where("enabled = ? AND start_time < ?", true, now).
|
Where("ever_enabled = ? AND start_time < ?", true, now).
|
||||||
Order("start_time ASC, id ASC").Find(&dailyActivities).Error; err != nil {
|
Order("start_time ASC, id ASC").Find(&dailyActivities).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -348,7 +350,7 @@ func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
|
|||||||
}
|
}
|
||||||
var activities []model.YumiGameKingActivity
|
var activities []model.YumiGameKingActivity
|
||||||
if err := s.db.WithContext(ctx).
|
if err := s.db.WithContext(ctx).
|
||||||
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, now, SettlementNotStarted).
|
Where("ever_enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, now, SettlementNotStarted).
|
||||||
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
|
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,7 @@ type gameKingDB interface {
|
|||||||
type gameKingGateway interface {
|
type gameKingGateway interface {
|
||||||
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
|
GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error)
|
||||||
SendActivityReward(context.Context, integration.SendActivityRewardRequest) error
|
SendActivityReward(context.Context, integration.SendActivityRewardRequest) error
|
||||||
|
SendFrozenActivityReward(context.Context, integration.SendFrozenActivityRewardRequest) error
|
||||||
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
|
MapUserProfiles(context.Context, []int64) (map[int64]integration.UserProfile, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +109,29 @@ func (v FlexibleInt64) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexibleString 接受资源 ID/金币数量的 JSON 字符串或数字,避免后台组件因资源类型不同而保存失败。
|
||||||
|
type FlexibleString string
|
||||||
|
|
||||||
|
func (v *FlexibleString) UnmarshalJSON(data []byte) error {
|
||||||
|
raw := strings.TrimSpace(string(data))
|
||||||
|
if raw == "" || raw == "null" {
|
||||||
|
*v = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||||
|
unquoted, err := strconv.Unquote(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*v = FlexibleString(unquoted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*v = FlexibleString(raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v FlexibleString) String() string { return string(v) }
|
||||||
|
|
||||||
type RewardItemView struct {
|
type RewardItemView struct {
|
||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@ -138,6 +162,7 @@ type ActivityView struct {
|
|||||||
RankingType string `json:"rankingType"`
|
RankingType string `json:"rankingType"`
|
||||||
RankingPeriod string `json:"rankingPeriod"`
|
RankingPeriod string `json:"rankingPeriod"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
EverEnabled bool `json:"everEnabled"`
|
||||||
SettlementStatus string `json:"settlementStatus"`
|
SettlementStatus string `json:"settlementStatus"`
|
||||||
CreateTime int64 `json:"createTime"`
|
CreateTime int64 `json:"createTime"`
|
||||||
UpdateTime int64 `json:"updateTime"`
|
UpdateTime int64 `json:"updateTime"`
|
||||||
@ -279,24 +304,41 @@ type SettlementRecordView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrizeInput struct {
|
type PrizeInput struct {
|
||||||
ID FlexibleInt64 `json:"id"`
|
ID FlexibleInt64 `json:"id"`
|
||||||
PrizeName string `json:"prizeName"`
|
PrizeName string `json:"prizeName"`
|
||||||
PrizeImage string `json:"prizeImage"`
|
PrizeImage string `json:"prizeImage"`
|
||||||
Weight int64 `json:"weight"`
|
Weight int64 `json:"weight"`
|
||||||
Stock int64 `json:"stock"`
|
Stock int64 `json:"stock"`
|
||||||
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
||||||
Enabled bool `json:"enabled"`
|
RewardItems []RewardItemInput `json:"rewardItems"`
|
||||||
SortOrder int `json:"sortOrder"`
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RankRewardInput struct {
|
type RankRewardInput struct {
|
||||||
ID FlexibleInt64 `json:"id"`
|
ID FlexibleInt64 `json:"id"`
|
||||||
RankingType string `json:"rankingType"`
|
RankingType string `json:"rankingType"`
|
||||||
RankingPeriod string `json:"rankingPeriod"`
|
RankingPeriod string `json:"rankingPeriod"`
|
||||||
StartRank int `json:"startRank"`
|
StartRank int `json:"startRank"`
|
||||||
EndRank int `json:"endRank"`
|
EndRank int `json:"endRank"`
|
||||||
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
||||||
RewardName string `json:"rewardName"`
|
RewardItems []RewardItemInput `json:"rewardItems"`
|
||||||
|
RewardName string `json:"rewardName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewardItemInput 是后台直接多选资源的冻结输入;不再要求运营先创建资源组。
|
||||||
|
// 展示字段随配置一起保存,实际发奖只使用 type/detailType/content/quantity/remark。
|
||||||
|
type RewardItemInput struct {
|
||||||
|
ID FlexibleInt64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DetailType string `json:"detailType"`
|
||||||
|
Content FlexibleString `json:"content"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
SourceURL string `json:"sourceUrl"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveActivityRequest struct {
|
type SaveActivityRequest struct {
|
||||||
|
|||||||
61
migrations/065_yumi_game_king_direct_rewards.sql
Normal file
61
migrations/065_yumi_game_king_direct_rewards.sql
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
-- Yumi Game King: direct multi-resource rewards and stop/resume settlement safety.
|
||||||
|
--
|
||||||
|
-- Performance review:
|
||||||
|
-- 1. Both ALTERs target bounded configuration/delivery tables; no game-event ledger,
|
||||||
|
-- wallet ledger or ranking aggregate is scanned.
|
||||||
|
-- 2. reward_items_json is nullable during the online DDL. The two bounded UPDATE JOINs use
|
||||||
|
-- owner_type/owner_id and prize/rank primary keys to freeze existing pending orders
|
||||||
|
-- before operators can change their activity configuration.
|
||||||
|
-- 3. ever_enabled is backfilled from the indexed enabled flag plus three activity-scoped
|
||||||
|
-- existence probes. Each probe uses an activity_id primary/index prefix and stops at
|
||||||
|
-- the first row, avoiding full scans of event or draw history.
|
||||||
|
-- 4. Run during low traffic with adequate temporary disk. LOCK=NONE keeps ordinary reads
|
||||||
|
-- and writes available, but MySQL metadata locks can still wait behind long transactions.
|
||||||
|
|
||||||
|
ALTER TABLE `yumi_game_king_activity`
|
||||||
|
ADD COLUMN `ever_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `enabled`,
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
UPDATE `yumi_game_king_activity` AS activity
|
||||||
|
SET activity.`ever_enabled` = 1
|
||||||
|
WHERE activity.`enabled` = 1
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `yumi_game_king_user` AS aggregate
|
||||||
|
WHERE aggregate.`activity_id` = activity.`id`
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `yumi_game_king_draw_record` AS draw_record
|
||||||
|
WHERE draw_record.`activity_id` = activity.`id`
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `yumi_game_king_period_settlement` AS period_gate
|
||||||
|
WHERE period_gate.`activity_id` = activity.`id`
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE `yumi_game_king_delivery_item`
|
||||||
|
ADD COLUMN `reward_items_json` TEXT NULL AFTER `resource_group_id`,
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
UPDATE `yumi_game_king_delivery_item` AS delivery
|
||||||
|
JOIN `yumi_game_king_draw_record` AS draw_record
|
||||||
|
ON delivery.`owner_type` = 'DRAW'
|
||||||
|
AND delivery.`owner_id` = draw_record.`id`
|
||||||
|
JOIN `yumi_game_king_prize` AS prize
|
||||||
|
ON prize.`id` = draw_record.`prize_id`
|
||||||
|
SET delivery.`reward_items_json` = prize.`reward_items_json`
|
||||||
|
WHERE delivery.`reward_items_json` IS NULL;
|
||||||
|
|
||||||
|
UPDATE `yumi_game_king_delivery_item` AS delivery
|
||||||
|
JOIN `yumi_game_king_settlement_record` AS settlement
|
||||||
|
ON delivery.`owner_type` = 'SETTLEMENT'
|
||||||
|
AND delivery.`owner_id` = settlement.`id`
|
||||||
|
JOIN `yumi_game_king_rank_reward` AS rank_reward
|
||||||
|
ON rank_reward.`id` = settlement.`rank_reward_id`
|
||||||
|
SET delivery.`reward_items_json` = rank_reward.`reward_items_json`
|
||||||
|
WHERE delivery.`reward_items_json` IS NULL;
|
||||||
Loading…
x
Reference in New Issue
Block a user