package yumigiftchallenge import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Settle 使用两阶段提交:先冻结门闩和获奖 parent,提交后再物化并发送奖励项。 func (s *Service) Settle(ctx context.Context, activityID int64, req SettlementRequest) error { target, err := s.resolveSettlementTarget(ctx, activityID, req) if err != nil { return err } if err := s.freezeSettlementTarget(ctx, target); err != nil { return err } return s.resumeSettlementTarget(ctx, target) } type settlementTarget struct { activity *model.YumiGiftChallengeActivity period string periodKey string statDate *string } func (s *Service) resolveSettlementTarget(ctx context.Context, activityID int64, req SettlementRequest) (settlementTarget, error) { activity, err := s.loadActivity(ctx, activityID) if err != nil { return settlementTarget{}, err } if !activity.Enabled { return settlementTarget{}, NewAppError(http.StatusConflict, "activity_disabled", "activity is disabled") } period := strings.ToUpper(strings.TrimSpace(req.PeriodType)) if period != PeriodDaily && period != PeriodOverall { return settlementTarget{}, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL") } location, err := resolveLocation(activity.Timezone) if err != nil { return settlementTarget{}, err } periodKey := PeriodOverall var statDate *string if period == PeriodDaily { if strings.TrimSpace(req.StatDate) == "" { return settlementTarget{}, NewAppError(http.StatusBadRequest, "stat_date_required", "statDate is required for DAILY settlement") } date, err := parseDateKey(req.StatDate, location) if err != nil { return settlementTarget{}, err } periodKey = dateKey(date, location) statDate = &periodKey } return settlementTarget{activity: activity, period: period, periodKey: periodKey, statDate: statDate}, nil } func (s *Service) freezeSettlementTarget(ctx context.Context, target settlementTarget) error { // 阶段一独立事务只负责关闭写门闩并冻结获奖 parent;阶段二失败不会重新开放榜单或重算获奖人。 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var header model.YumiGiftChallengePeriodSettlement queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey, ).First(&header).Error if errors.Is(queryErr, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "settlement_gate_not_found", "settlement gate not found") } if queryErr != nil { return queryErr } if time.Now().Before(header.SnapshotDueTime) { return NewAppError(http.StatusConflict, "settlement_not_due", "settlement delay window has not elapsed") } if header.Status != StatusNotStarted { return nil } if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID). Updates(map[string]any{"status": StatusProcessing, "update_time": time.Now()}).Error; err != nil { return err } if target.period == PeriodOverall { if err := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", target.activity.ID). Updates(map[string]any{"overall_settlement_status": StatusProcessing, "update_time": time.Now()}).Error; err != nil { return err } } return s.freezeSettlementParentsTx(tx, *target.activity, target.period, target.periodKey, target.statDate) }) } func (s *Service) resumeSettlementTarget(ctx context.Context, target settlementTarget) error { var parents []model.YumiGiftChallengeSettlement if err := s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", target.activity.ID, target.period, target.periodKey). Order("rank_no ASC").Find(&parents).Error; err != nil { return err } for _, parent := range parents { if err := ctx.Err(); err != nil { return err } if err := s.ensureSettlementDeliveryItems(ctx, parent.ID); err != nil { // parent 和门闩已经提交,返回错误让运营看到;再次执行同一结算会从固定 parent 继续,不重算榜单。 return err } if err := s.deliverOwnerItems(ctx, OwnerSettlement, parent.ID); err != nil { return err } } return s.refreshPeriodStatus(ctx, target.activity.ID, target.period, target.periodKey) } func (s *Service) freezeSettlementParentsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, period, periodKey string, statDate *string) error { var rewardConfigs []model.YumiGiftChallengeRankReward if err := tx.Where("activity_id = ? AND period_type = ?", activity.ID, period). Order("start_rank ASC").Find(&rewardConfigs).Error; err != nil { return err } maxRank := 0 for _, reward := range rewardConfigs { if reward.EndRank > maxRank { maxRank = reward.EndRank } } if maxRank == 0 { return NewAppError(http.StatusConflict, "rank_reward_missing", "rank reward configuration is missing") } rows, err := s.loadRankRowsTx(tx, activity.ID, period, statDate, maxRank) if err != nil { return err } now := time.Now() for _, row := range rows { reward := matchRankReward(rewardConfigs, row.Rank) if reward == nil { continue } id, err := utils.NextID() if err != nil { return err } parent := model.YumiGiftChallengeSettlement{ ID: id, ActivityID: activity.ID, PeriodType: period, PeriodKey: periodKey, StatDate: statDate, UserID: row.UserID, RankNo: row.Rank, Score: row.Score, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID, BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:RANK:%d:%s:%s:%d", activity.ID, period, periodKey, row.UserID), DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&parent).Error; err != nil { return err } } return nil } func (s *Service) loadRankRowsTx(tx *gorm.DB, activityID int64, period string, statDate *string, limit int) ([]rawRankRow, error) { result := make([]rawRankRow, 0, limit) if period == PeriodDaily { var rows []model.YumiGiftChallengeUserDailyScore if err := tx.Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, *statDate). Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1}) } return result, nil } var rows []model.YumiGiftChallengeUserScore if err := tx.Where("activity_id = ? AND score > 0", activityID). Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { result = append(result, rawRankRow{UserID: row.UserID, Score: row.Score, Rank: index + 1}) } return result, nil } func matchRankReward(rows []model.YumiGiftChallengeRankReward, rank int) *model.YumiGiftChallengeRankReward { for index := range rows { if rank >= rows[index].StartRank && rank <= rows[index].EndRank { return &rows[index] } } return nil } func (s *Service) ensureSettlementDeliveryItems(ctx context.Context, settlementID int64) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var parent model.YumiGiftChallengeSettlement if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", settlementID).First(&parent).Error; err != nil { return err } groupID := parent.ResourceGroupID return s.createDeliveryItemsTx(tx, OwnerSettlement, parent.ID, parent.ActivityID, parent.UserID, &groupID) }) } func (s *Service) refreshPeriodStatus(ctx context.Context, activityID int64, period, periodKey string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var header model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey, ).First(&header).Error; err != nil { return err } var total, unfinished, unknown int64 base := tx.Model(&model.YumiGiftChallengeSettlement{}). Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, period, periodKey) if err := base.Count(&total).Error; err != nil { return err } if total > 0 { if err := tx.Model(&model.YumiGiftChallengeSettlement{}). Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status <> ?", activityID, period, periodKey, DeliverySuccess). Count(&unfinished).Error; err != nil { return err } if err := tx.Model(&model.YumiGiftChallengeSettlement{}). Where("activity_id = ? AND period_type = ? AND period_key = ? AND delivery_status = ?", activityID, period, periodKey, DeliveryUnknown). Count(&unknown).Error; err != nil { return err } } status := StatusCompleted if unfinished > 0 { status = StatusProcessing if unknown > 0 { status = StatusReconciliationRequired } } if err := tx.Model(&model.YumiGiftChallengePeriodSettlement{}).Where("id = ?", header.ID). Updates(map[string]any{"status": status, "update_time": time.Now()}).Error; err != nil { return err } if period == PeriodOverall { return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{"overall_settlement_status": status, "update_time": time.Now()}).Error } return nil }) } // PageSettlementRecords 分页查询获奖 parent 及逐奖励项状态,供运营核账。 func (s *Service) PageSettlementRecords(ctx context.Context, activityID int64, period, statDate, status string, page, size int) (map[string]any, error) { if _, err := s.loadActivity(ctx, activityID); err != nil { return nil, err } if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeSettlement{}).Where("activity_id = ?", activityID) if period = strings.ToUpper(strings.TrimSpace(period)); period != "" { query = query.Where("period_type = ?", period) } if strings.TrimSpace(statDate) != "" { query = query.Where("period_key = ?", strings.TrimSpace(statDate)) } if status = strings.ToUpper(strings.TrimSpace(status)); status != "" { query = query.Where("delivery_status = ?", status) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.YumiGiftChallengeSettlement if err := query.Order("create_time DESC, rank_no ASC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { return nil, err } records := make([]SettlementRecordView, 0, len(rows)) for _, row := range rows { var items []model.YumiGiftChallengeDeliveryItem if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, row.ID). Order("sort_order ASC, id ASC").Find(&items).Error; err != nil { return nil, err } itemViews := make([]DeliveryItemView, 0, len(items)) for _, item := range items { itemViews = append(itemViews, DeliveryItemView{ ID: item.ID, OwnerType: item.OwnerType, OwnerID: item.OwnerID, RewardConfigID: item.RewardConfigID, ResourceGroupID: item.ResourceGroupID, RewardType: item.RewardType, DetailType: item.DetailType, Content: item.Content, Quantity: item.Quantity, SortOrder: item.SortOrder, Remark: item.Remark, DeliveryStatus: item.DeliveryStatus, RetryCount: item.RetryCount, FailureReason: item.FailureReason, DeliverTime: millis(item.DeliverTime), }) } view := SettlementRecordView{ ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType, PeriodKey: row.PeriodKey, UserID: row.UserID, Rank: row.RankNo, Score: row.Score, RankRewardID: row.RankRewardID, ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason, CreateTime: row.CreateTime.UnixMilli(), DeliverTime: millis(row.DeliverTime), DeliveryItems: itemViews, } if row.StatDate != nil { view.StatDate = *row.StatDate } records = append(records, view) } return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil }