581 lines
21 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"
"fmt"
"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, rankingTypeVictorious},
SupportedRankingPeriods: []string{rankingPeriodDaily, rankingPeriodOverall},
ActivityStatus: activityStatus(activity, now),
ServerTime: now.UnixMilli(),
}, nil
}
// SaveActivity 新增或修改活动。统计口径开始后保持不可变,直接资源奖励和启停可继续维护;
// 已产生的中奖/结算记录会使用各自 delivery item 内的冻结快照。
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
// 编辑配置不隐式携带启停意图;活动状态只能走独立 enable 接口,避免旧表单保存
// 把另一位运营刚执行的启停覆盖掉。
normalized.Enabled = row.Enabled
}
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", "DAILY Top3 and OVERALL Top30 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.EverEnabled && !writeTime.Before(row.StartTime) {
if err := validateStartedActivityUpdate(row, normalized); err != nil {
return err
}
}
} 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")
}
// 已启用但尚未开始的活动仍可能修改时间窗;只要本次保存结果为 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.DailyChanceLimit = normalized.DailyChanceLimit
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
}
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 可在活动生命周期内随时启停;关闭只阻止新入账和抽奖,
// 已经产生的数据仍由 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
}
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 enabled {
if err := ensureActivityEnableSafetyTx(tx); err != nil {
return err
}
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
}
}
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
})
}
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
}
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
}
return replacePrizesTx(tx, activityID, values, time.Now())
})
}
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
}
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
}
return replaceRankRewardsTx(tx, activityID, values, time.Now())
})
}
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("ranking_period ASC, ranking_type ASC, 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),
RewardItems: rewardItemInputs(row.RewardItemsJSON), 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), 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 {
var activity model.YumiGameKingActivity
if err := tx.Select("id", "start_time", "ever_enabled").Where("id = ?", activityID).First(&activity).Error; err != nil {
return err
}
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 {
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
}
}
return nil
}
func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRankReward, now time.Time) error {
var existing []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ?", activityID).Find(&existing).Error; err != nil {
return err
}
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 {
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
}
}
return nil
}
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.DailyChanceLimit != req.DailyChanceLimit ||
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
}