2026-07-18 15:32:58 +08:00

315 lines
12 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 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
}