545 lines
20 KiB
Go
545 lines
20 KiB
Go
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
|
||
}
|