package gameking import ( "context" "errors" "net/http" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ListActivities 返回后台活动列表,活动数量很小,避免引入无意义分页状态。 func (s *Service) ListActivities(ctx context.Context) ([]ActivityView, error) { var rows []model.YumiGameKingActivity if err := s.db.WithContext(ctx).Order("id DESC").Find(&rows).Error; err != nil { return nil, err } result := make([]ActivityView, 0, len(rows)) for _, row := range rows { result = append(result, activityView(row)) } return result, nil } // ManageDetail 返回后台活动及其七奖项、六排名档完整配置。 func (s *Service) ManageDetail(ctx context.Context, activityID int64) (*DetailResponse, error) { activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } return s.buildDetail(ctx, activity) } // Detail 返回当前登录用户所属平台可见的活动详情。 func (s *Service) Detail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) { activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now()) if err != nil { return nil, err } return s.buildDetail(ctx, activity) } func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingActivity) (*DetailResponse, error) { prizes, err := s.loadPrizes(ctx, activity.ID) if err != nil { return nil, err } rankRewards, err := s.loadRankRewards(ctx, activity.ID) if err != nil { return nil, err } prizeViews := make([]PrizeView, 0, len(prizes)) for _, row := range prizes { prizeViews = append(prizeViews, prizeView(row)) } rankViews := make([]RankRewardView, 0, len(rankRewards)) for _, row := range rankRewards { rankViews = append(rankViews, rankRewardView(row)) } now := time.Now() return &DetailResponse{ Activity: activityView(activity), Prizes: prizeViews, RankRewards: rankViews, SupportedRankingTypes: []string{rankingTypeTycoon}, SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall}, ActivityStatus: activityStatus(activity, now), ServerTime: now.UnixMilli(), }, nil } // SaveActivity 新增或修改活动。首次启用前会重新读取全部奖励组并冻结展示快照。 func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*ActivityView, error) { normalized, err := normalizeActivityInput(req) if err != nil { return nil, err } var existing *model.YumiGameKingActivity if normalized.ID.Int64() > 0 { row, loadErr := s.loadActivity(ctx, normalized.ID.Int64()) if loadErr != nil { return nil, loadErr } existing = &row } var normalizedPrizes []normalizedPrize if normalized.Prizes != nil { normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, normalized.Prizes) if err != nil { return nil, err } } var normalizedRanks []normalizedRankReward if normalized.RankRewards != nil { normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, normalized.RankRewards) if err != nil { return nil, err } } // 只在“首次启用/重新启用”的开始前状态补齐并刷新子配置。已经开始且仍为 enabled // 的元数据编辑绝不能走这条路径,否则会重建奖品 ID、刷新快照并把已消耗库存写回旧值。 needsEnableValidation := normalized.Enabled && (existing == nil || !existing.Enabled) if needsEnableValidation { if normalizedPrizes == nil { if existing == nil { return nil, NewAppError(http.StatusBadRequest, "prizes_required", "seven prizes are required before enabling") } inputs, loadErr := s.currentPrizeInputs(ctx, existing.ID) if loadErr != nil { return nil, loadErr } normalizedPrizes, err = s.normalizePrizes(ctx, normalized.SysOrigin, inputs) if err != nil { return nil, err } } if normalizedRanks == nil { if existing == nil { return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "six rank reward tiers are required before enabling") } inputs, loadErr := s.currentRankInputs(ctx, existing.ID) if loadErr != nil { return nil, loadErr } normalizedRanks, err = s.normalizeRankRewards(ctx, normalized.SysOrigin, inputs) if err != nil { return nil, err } } } activityID := normalized.ID.Int64() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row model.YumiGameKingActivity created := activityID <= 0 writeTime := time.Now() if !created { if err := withWriteLock(tx).Where("id = ?", activityID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found") } return err } // 奖励组远程校验和数据库锁等待都可能跨过 startTime,必须在拿到排他锁后 // 重新取时钟,不能用事务外的旧 now 绕过开始锁。 writeTime = time.Now() if row.Enabled && !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() if err != nil { return err } row = model.YumiGameKingActivity{ID: activityID, SettlementStatus: SettlementNotStarted, CreateTime: writeTime} } // 事务外读取时可能仍为 enabled,但在等待锁期间被另一个管理员关闭。此时本请求 // 没有执行重新启用所需的奖励组校验,必须失败并让调用方重试,不能用旧快照启用。 if normalized.Enabled && !row.Enabled && (normalizedPrizes == nil || normalizedRanks == nil) { 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 { if err := ensureActivityEnableSafetyTx(tx); err != nil { return err } } if normalized.Enabled { if err := lockActivityScopeTx(tx, normalized.SysOrigin, writeTime); err != nil { return err } if err := ensureNoEnabledOverlap(tx, activityID, normalized.SysOrigin, time.UnixMilli(normalized.StartTime), time.UnixMilli(normalized.EndTime)); err != nil { return err } } var codeCount int64 if err := tx.Model(&model.YumiGameKingActivity{}). Where("activity_code = ? AND id <> ?", normalized.ActivityCode, activityID). Count(&codeCount).Error; err != nil { return err } if codeCount > 0 { return NewAppError(http.StatusConflict, "activity_code_exists", "activityCode already exists") } row.ActivityCode = normalized.ActivityCode row.ActivityName = normalized.ActivityName row.ActivityDesc = normalized.ActivityDesc row.SysOrigin = normalized.SysOrigin row.Timezone = normalized.Timezone row.StartTime = time.UnixMilli(normalized.StartTime) row.EndTime = time.UnixMilli(normalized.EndTime) row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute) row.CoinPerDraw = normalized.CoinPerDraw row.RankingType = normalized.RankingType row.RankingPeriod = normalized.RankingPeriod row.Enabled = normalized.Enabled row.UpdateTime = writeTime if row.SettlementStatus == "" { row.SettlementStatus = SettlementNotStarted } if err := tx.Save(&row).Error; err != nil { return err } if normalizedPrizes != nil { if err := replacePrizesTx(tx, activityID, normalizedPrizes, writeTime); err != nil { return err } } if normalizedRanks != nil { if err := replaceRankRewardsTx(tx, activityID, normalizedRanks, writeTime); err != nil { return err } } return nil }); err != nil { return nil, err } row, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } view := activityView(row) return &view, nil } // EnableActivity 在活动开始前启停活动;启用会重新验证并刷新全部奖励快照。 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 { prizeInputs, loadErr := s.currentPrizeInputs(ctx, activityID) if loadErr != nil { return loadErr } prizes, err = s.normalizePrizes(ctx, activity.SysOrigin, prizeInputs) if err != nil { return err } rankInputs, loadErr := s.currentRankInputs(ctx, activityID) if loadErr != nil { return loadErr } ranks, err = s.normalizeRankRewards(ctx, activity.SysOrigin, rankInputs) if err != nil { return err } } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.YumiGameKingActivity 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", "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 } if err := ensureNoEnabledOverlap(tx, activityID, locked.SysOrigin, locked.StartTime, locked.EndTime); err != nil { return err } if err := replacePrizesTx(tx, activityID, prizes, lockedNow); err != nil { return err } if err := replaceRankRewardsTx(tx, activityID, ranks, lockedNow); err != nil { return err } } return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID). Updates(map[string]any{"enabled": enabled, "update_time": lockedNow}).Error }) } func (s *Service) ListPrizes(ctx context.Context, activityID int64) ([]PrizeView, error) { if _, err := s.loadActivity(ctx, activityID); err != nil { return nil, err } rows, err := s.loadPrizes(ctx, activityID) if err != nil { return nil, err } result := make([]PrizeView, 0, len(rows)) for _, row := range rows { result = append(result, prizeView(row)) } return result, nil } func (s *Service) SavePrizes(ctx context.Context, activityID int64, req SavePrizesRequest) error { if req.ActivityID.Int64() != activityID { return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id") } 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", "prizes cannot change after activity start") } values, err := s.normalizePrizes(ctx, activity.SysOrigin, req.Prizes) if err != nil { return err } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.YumiGameKingActivity 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) }) } func (s *Service) ListRankRewards(ctx context.Context, activityID int64) ([]RankRewardView, error) { if _, err := s.loadActivity(ctx, activityID); err != nil { return nil, err } rows, err := s.loadRankRewards(ctx, activityID) if err != nil { return nil, err } result := make([]RankRewardView, 0, len(rows)) for _, row := range rows { result = append(result, rankRewardView(row)) } return result, nil } func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req SaveRankRewardsRequest) error { if req.ActivityID.Int64() != activityID { return NewAppError(http.StatusBadRequest, "activity_id_mismatch", "body activityId must match path id") } 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", "rank rewards cannot change after activity start") } values, err := s.normalizeRankRewards(ctx, activity.SysOrigin, req.RankRewards) if err != nil { return err } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.YumiGameKingActivity 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) }) } func (s *Service) loadActivity(ctx context.Context, activityID int64) (model.YumiGameKingActivity, error) { if activityID <= 0 { return model.YumiGameKingActivity{}, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required") } var row model.YumiGameKingActivity if err := s.db.WithContext(ctx).Where("id = ?", activityID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return row, NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found") } return row, err } return row, nil } func (s *Service) resolveAppActivity(ctx context.Context, sysOrigin string, activityID int64, now time.Time) (model.YumiGameKingActivity, error) { var row model.YumiGameKingActivity base := s.db.WithContext(ctx).Where("sys_origin = ? AND enabled = ?", sysOrigin, true) if activityID > 0 { if err := base.Where("id = ?", activityID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found") } return row, err } return row, nil } for _, query := range []*gorm.DB{ base.Session(&gorm.Session{}).Where("start_time <= ? AND end_time > ?", now, now).Order("start_time DESC"), base.Session(&gorm.Session{}).Where("start_time > ?", now).Order("start_time ASC"), base.Session(&gorm.Session{}).Where("end_time <= ?", now).Order("end_time DESC"), } { if err := query.First(&row).Error; err == nil { return row, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return row, err } } return row, NewAppError(http.StatusNotFound, "activity_not_found", "enabled game king activity was not found") } func (s *Service) loadPrizes(ctx context.Context, activityID int64) ([]model.YumiGameKingPrize, error) { var rows []model.YumiGameKingPrize err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC").Find(&rows).Error return rows, err } func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) { var rows []model.YumiGameKingRankReward err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("start_rank ASC").Find(&rows).Error return rows, err } func (s *Service) currentPrizeInputs(ctx context.Context, activityID int64) ([]PrizeInput, error) { rows, err := s.loadPrizes(ctx, activityID) if err != nil { return nil, err } 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}) } return values, nil } func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]RankRewardInput, error) { rows, err := s.loadRankRewards(ctx, activityID) if err != nil { return nil, err } values := make([]RankRewardInput, 0, len(rows)) for _, row := range rows { values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), 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 { return err } rows := make([]model.YumiGameKingPrize, 0, len(values)) for _, value := range values { id, err := utils.NextID() if 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 } 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 { return err } rows := make([]model.YumiGameKingRankReward, 0, len(values)) for _, value := range values { id, err := utils.NextID() if err != nil { return err } rows = append(rows, model.YumiGameKingRankReward{ ID: id, ActivityID: activityID, 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 } func ensureNoEnabledOverlap(tx *gorm.DB, activityID int64, sysOrigin string, start, end time.Time) error { var count int64 if err := tx.Model(&model.YumiGameKingActivity{}). Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", sysOrigin, true, activityID, end, start). Count(&count).Error; err != nil { return err } if count > 0 { return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activities for the same sysOrigin cannot overlap") } return nil } func lockActivityScopeTx(tx *gorm.DB, sysOrigin string, now time.Time) error { row := model.YumiGameKingScopeLock{SysOrigin: sysOrigin, UpdateTime: now} if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "sys_origin"}}, DoNothing: true, }).Create(&row).Error; err != nil { return err } // 插入竞争失败后,显式 FOR UPDATE 等待持锁事务提交;锁一直持有到本次 overlap // 检查和 activity 保存一起提交,两个不同 activity 行也无法并发穿透。 return withWriteLock(tx).Where("sys_origin = ?", sysOrigin).First(&row).Error } func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActivityRequest) error { if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone || row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime || row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw || 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") } return nil }