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