diff --git a/internal/integration/java.go b/internal/integration/java.go index 17148bf..a454b83 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -772,7 +772,7 @@ func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardR // SendFrozenActivityReward 通过 Java 已有的 PrizeDescribe 接口发放一个冻结奖励项。 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") } var resp resultResponse[any] diff --git a/internal/model/yumi_game_king_models.go b/internal/model/yumi_game_king_models.go index b6204ac..efddfa1 100644 --- a/internal/model/yumi_game_king_models.go +++ b/internal/model/yumi_game_king_models.go @@ -19,6 +19,7 @@ type YumiGameKingActivity struct { RankingType string `gorm:"column:ranking_type;size:32"` RankingPeriod string `gorm:"column:ranking_period;size:32"` 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"` CreateTime time.Time `gorm:"column:create_time"` UpdateTime time.Time `gorm:"column:update_time"` @@ -197,7 +198,7 @@ func (YumiGameKingSettlementRecord) TableName() string { return "yumi_game_king_settlement_record" } -// YumiGameKingDeliveryItem 把一次奖励组调用作为最小核账单元;UNKNOWN 不允许自动重试。 +// YumiGameKingDeliveryItem 冻结一次中奖或结算对应的直接资源列表;UNKNOWN 不允许自动重试。 type YumiGameKingDeliveryItem struct { 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"` @@ -205,6 +206,7 @@ type YumiGameKingDeliveryItem struct { ActivityID int64 `gorm:"column:activity_id;index:idx_yumi_gk_delivery_activity,priority:1"` UserID int64 `gorm:"column:user_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"` RetryCount int `gorm:"column:retry_count"` FailureReason string `gorm:"column:failure_reason;size:500"` diff --git a/internal/service/gameking/config.go b/internal/service/gameking/config.go index 10feabd..351403b 100644 --- a/internal/service/gameking/config.go +++ b/internal/service/gameking/config.go @@ -3,6 +3,7 @@ package gameking import ( "context" "errors" + "fmt" "net/http" "time" @@ -73,7 +74,8 @@ func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingAc }, nil } -// SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。 +// SaveActivity 新增或修改活动。统计口径开始后保持不可变,直接资源奖励和启停可继续维护; +// 已产生的中奖/结算记录会使用各自 delivery item 内的冻结快照。 func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) { normalized, err := normalizeActivityInput(req) if err != nil { @@ -87,6 +89,9 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A return nil, loadErr } existing = &row + // 编辑配置不隐式携带启停意图;活动状态只能走独立 enable 接口,避免旧表单保存 + // 把另一位运营刚执行的启停覆盖掉。 + normalized.Enabled = row.Enabled } var normalizedPrizes []normalizedPrize @@ -151,13 +156,10 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A // 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后 // 重新取时钟,不能用事务外的旧 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 { return err } - if normalizedPrizes != nil || normalizedRanks != nil { - return NewAppError(http.StatusConflict, "activity_config_locked", "prizes and rank rewards cannot change after activity start") - } } } else { 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") } - 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, // 就与 GC 删除事务锁同一控制行,避免 GC 按旧窗口保护后活动扩展到已删除区间。 if normalized.Enabled { @@ -214,6 +213,9 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A row.RankingType = normalized.RankingType row.RankingPeriod = normalized.RankingPeriod row.Enabled = normalized.Enabled + if normalized.Enabled { + row.EverEnabled = true + } row.UpdateTime = writeTime if row.SettlementStatus == "" { row.SettlementStatus = SettlementNotStarted @@ -244,15 +246,13 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A return &view, nil } -// EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。 +// EnableActivity 可在活动生命周期内随时启停;关闭只阻止新入账和抽奖, +// 已经产生的数据仍由 ever_enabled 驱动日榜和总榜结算。 func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled bool) error { activity, err := s.loadActivity(ctx, activityID) if err != nil { 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 ranks []normalizedRankReward if enabled { @@ -279,16 +279,10 @@ func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled return err } 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 err := ensureActivityEnableSafetyTx(tx); err != nil { 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 { return err } @@ -302,8 +296,11 @@ func (s *Service) EnableActivity(ctx context.Context, activityID int64, enabled return err } } - return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID). - Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error + updates := map[string]any{"enabled": enabled, "update_time": lockedNow} + 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 { 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) if err != nil { 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 { return err } - lockedNow := 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) + return replacePrizesTx(tx, activityID, values, time.Now()) }) } @@ -373,9 +363,6 @@ func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req Sav if err != nil { 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) if err != nil { 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 { return err } - lockedNow := 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) + return replaceRankRewardsTx(tx, activityID, values, time.Now()) }) } @@ -452,7 +435,11 @@ func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]P } values := make([]PrizeInput, 0, len(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 } @@ -464,48 +451,82 @@ func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]Ra } values := make([]RankRewardInput, 0, len(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 } 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 } - 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 { - id, err := utils.NextID() - if err != nil { + row, exists := bySort[value.Input.SortOrder] + 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 } - 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 { - 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 } - 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 { - id, err := utils.NextID() - if err != nil { + key := fmt.Sprintf("%s:%s:%d:%d", value.Input.RankingPeriod, value.Input.RankingType, value.Input.StartRank, value.Input.EndRank) + 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 } - 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 { @@ -539,8 +560,21 @@ func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActiv row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime || row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw || row.DailyChanceLimit != req.DailyChanceLimit || - row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled { - return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start") + row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod { + return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time and chance rule cannot change after activity start") } 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 +} diff --git a/internal/service/gameking/delivery.go b/internal/service/gameking/delivery.go index a4e7a1c..12acbb2 100644 --- a/internal/service/gameking/delivery.go +++ b/internal/service/gameking/delivery.go @@ -23,7 +23,7 @@ type frozenDeliveryContext struct { RewardItems []RewardItemView } -// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。 +// dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 直接资源快照并调用 Java。 // 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。 func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error { // 事务已提交后发奖不能继承 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()) 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 { err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable") _ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error()) return err } - err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{ - TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin, - SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID, - }) + if strings.TrimSpace(item.RewardItemsJSON) == "" && item.ResourceGroupID > 0 { + // 迁移前已创建的 delivery item 沿用资源组发奖,避免发布过程中中断旧订单。 + 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 { // HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN, // 禁止自动或普通 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 { return frozenDeliveryContext{}, err } - result := frozenDeliveryContext{SysOrigin: activity.SysOrigin} + result := frozenDeliveryContext{SysOrigin: activity.SysOrigin, RewardItemsJSON: item.RewardItemsJSON} switch item.OwnerType { case OwnerDraw: var record model.YumiGameKingDrawRecord if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil { 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.RewardItemsJSON = prize.RewardItemsJSON - result.RewardItems = rewardItems(prize.RewardItemsJSON) + if strings.TrimSpace(result.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: var record model.YumiGameKingSettlementRecord if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil { 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.RewardItemsJSON = rankReward.RewardItemsJSON - result.RewardItems = rewardItems(rankReward.RewardItemsJSON) + if strings.TrimSpace(result.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: 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 { return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty") } diff --git a/internal/service/gameking/draw.go b/internal/service/gameking/draw.go index e22f438..0d47631 100644 --- a/internal/service/gameking/draw.go +++ b/internal/service/gameking/draw.go @@ -118,8 +118,9 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr } if err := tx.Create(&model.YumiGameKingDeliveryItem{ ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID, - UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending, - CreateTime: txNow, UpdateTime: txNow, + UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, RewardItemsJSON: prize.RewardItemsJSON, + DeliveryStatus: DeliveryPending, + CreateTime: txNow, UpdateTime: txNow, }).Error; err != nil { return err } @@ -217,18 +218,8 @@ func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingP Order("sort_order ASC").Find(&rows).Error; err != nil { 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 { - 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) if err != nil { diff --git a/internal/service/gameking/normalize.go b/internal/service/gameking/normalize.go index 5f13649..4c09bf6 100644 --- a/internal/service/gameking/normalize.go +++ b/internal/service/gameking/normalize.go @@ -8,6 +8,7 @@ import ( "math" "net/http" "sort" + "strconv" "strings" "time" @@ -126,6 +127,7 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values } seenSort := make(map[int]struct{}, 7) result := make([]normalizedPrize, 0, 7) + enabledCount := 0 for _, value := range values { value.PrizeName = strings.TrimSpace(value.PrizeName) 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 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters") } - if !value.Enabled { - return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled") + if value.Enabled { + enabledCount++ } if value.Weight <= 0 { 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") } seenSort[value.SortOrder] = struct{}{} - if value.ResourceGroupID.Int64() <= 0 { - 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()) + snapshot, err := s.normalizeConfiguredReward(ctx, sysOrigin, value.ResourceGroupID.Int64(), value.RewardItems) if err != nil { return nil, err } 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 }) return result, nil } @@ -202,11 +204,8 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va if !exists { 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) - snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64()) + snapshot, err := s.normalizeConfiguredReward(ctx, sysOrigin, value.ResourceGroupID.Int64(), value.RewardItems) if err != nil { return nil, err } @@ -223,6 +222,68 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va 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) { if s.gateway == nil { 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, RankingPeriod: row.RankingPeriod, Enabled: row.Enabled, + EverEnabled: row.EverEnabled, SettlementStatus: row.SettlementStatus, CreateTime: unixMilli(row.CreateTime), UpdateTime: unixMilli(row.UpdateTime), diff --git a/internal/service/gameking/settlement.go b/internal/service/gameking/settlement.go index dec5f70..ad28901 100644 --- a/internal/service/gameking/settlement.go +++ b/internal/service/gameking/settlement.go @@ -59,8 +59,8 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error return err } now := time.Now() - if !activity.Enabled { - return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled") + if !activity.EverEnabled { + return NewAppError(http.StatusConflict, "activity_never_enabled", "activity has never been enabled") } if now.Before(activity.SettlementTime) { 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{ ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID, UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID, - DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, + RewardItemsJSON: reward.RewardItemsJSON, DeliveryStatus: DeliveryPending, + CreateTime: now, UpdateTime: now, }).Error; err != nil { 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 { return err } - if !activity.Enabled { - return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled") + if !activity.EverEnabled { + return NewAppError(http.StatusConflict, "activity_never_enabled", "activity has never been enabled") } if time.Now().Before(dueTime) { 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{ ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID, UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID, - DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, + RewardItemsJSON: reward.RewardItemsJSON, DeliveryStatus: DeliveryPending, + CreateTime: now, UpdateTime: now, }).Error; err != nil { return err } @@ -318,7 +320,7 @@ func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) { now := time.Now() var dailyActivities []model.YumiGameKingActivity 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 { return nil, err } @@ -348,7 +350,7 @@ func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) { } var activities []model.YumiGameKingActivity 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 { return nil, err } diff --git a/internal/service/gameking/types.go b/internal/service/gameking/types.go index e1a6d3a..fdd951a 100644 --- a/internal/service/gameking/types.go +++ b/internal/service/gameking/types.go @@ -64,6 +64,7 @@ type gameKingDB interface { type gameKingGateway interface { GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error) SendActivityReward(context.Context, integration.SendActivityRewardRequest) error + SendFrozenActivityReward(context.Context, integration.SendFrozenActivityRewardRequest) 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)) } +// 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 { ID int64 `json:"id,string"` Type string `json:"type"` @@ -138,6 +162,7 @@ type ActivityView struct { RankingType string `json:"rankingType"` RankingPeriod string `json:"rankingPeriod"` Enabled bool `json:"enabled"` + EverEnabled bool `json:"everEnabled"` SettlementStatus string `json:"settlementStatus"` CreateTime int64 `json:"createTime"` UpdateTime int64 `json:"updateTime"` @@ -279,24 +304,41 @@ type SettlementRecordView struct { } type PrizeInput struct { - ID FlexibleInt64 `json:"id"` - PrizeName string `json:"prizeName"` - PrizeImage string `json:"prizeImage"` - Weight int64 `json:"weight"` - Stock int64 `json:"stock"` - ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` - Enabled bool `json:"enabled"` - SortOrder int `json:"sortOrder"` + ID FlexibleInt64 `json:"id"` + PrizeName string `json:"prizeName"` + PrizeImage string `json:"prizeImage"` + Weight int64 `json:"weight"` + Stock int64 `json:"stock"` + ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` + RewardItems []RewardItemInput `json:"rewardItems"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` } type RankRewardInput struct { - ID FlexibleInt64 `json:"id"` - RankingType string `json:"rankingType"` - RankingPeriod string `json:"rankingPeriod"` - StartRank int `json:"startRank"` - EndRank int `json:"endRank"` - ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` - RewardName string `json:"rewardName"` + ID FlexibleInt64 `json:"id"` + RankingType string `json:"rankingType"` + RankingPeriod string `json:"rankingPeriod"` + StartRank int `json:"startRank"` + EndRank int `json:"endRank"` + ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` + 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 { diff --git a/migrations/065_yumi_game_king_direct_rewards.sql b/migrations/065_yumi_game_king_direct_rewards.sql new file mode 100644 index 0000000..0b40fce --- /dev/null +++ b/migrations/065_yumi_game_king_direct_rewards.sql @@ -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;