2026-07-18 18:56:15 +08:00

545 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}