package yumigiftchallenge import ( "context" "fmt" "time" "chatapp3-golang/internal/model" ) const ( defaultSettleDueBatch = 20 maxSettleDueBatch = 100 settleDueItemTimeout = 30 * time.Second ) // SettleDue 是 chatapp-cron 的单轮幂等 runner:冻结到期周期,并恢复 PENDING/过租期 PROCESSING。 // 本仓库不启动 ticker;同一端点可被多个实例重复调用,数据库门闩和 delivery item 唯一键负责并发幂等。 func (s *Service) SettleDue(ctx context.Context, batch int) (*SettleDueResult, error) { batch = normalizeSettleDueBatch(batch) result := &SettleDueResult{FailedKeys: []string{}} var periods []model.YumiGiftChallengePeriodSettlement if err := s.db.WithContext(ctx). Where("status IN ? AND snapshot_due_time <= ?", []string{StatusNotStarted, StatusProcessing}, time.Now()). Order("snapshot_due_time ASC, id ASC").Limit(batch).Find(&periods).Error; err != nil { return nil, err } for _, period := range periods { if err := ctx.Err(); err != nil { return result, err } result.PeriodsScanned++ request := SettlementRequest{PeriodType: period.PeriodType} if period.StatDate != nil { request.StatDate = *period.StatDate } itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout) target, err := s.resolveSettlementTarget(itemCtx, period.ActivityID, request) if err == nil { err = s.freezeSettlementTarget(itemCtx, target) } if err == nil { // 空榜直接完成;有获奖 parent 时保持 PROCESSING,后面的有界 delivery 恢复负责逐项发放。 err = s.refreshPeriodStatus(itemCtx, target.activity.ID, target.period, target.periodKey) } cancel() if err != nil { result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("PERIOD:%d:%s:%s", period.ActivityID, period.PeriodType, period.PeriodKey)) continue } result.PeriodsFrozen++ } if err := s.recoverTaskOwners(ctx, batch, result); err != nil { return result, err } if err := s.recoverSettlementOwners(ctx, batch, result); err != nil { return result, err } return result, nil } func (s *Service) recoverTaskOwners(ctx context.Context, batch int, result *SettleDueResult) error { var rows []model.YumiGiftChallengeUserTaskDaily if err := s.db.WithContext(ctx).Select("id"). Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()). Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil { return err } for _, row := range rows { if err := ctx.Err(); err != nil { return err } result.TaskOwnersScanned++ itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout) err := s.deliverOwnerItems(itemCtx, OwnerTask, row.ID) cancel() if err != nil { result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("TASK:%d", row.ID)) continue } result.OwnersProcessed++ } return nil } func (s *Service) recoverSettlementOwners(ctx context.Context, batch int, result *SettleDueResult) error { var rows []model.YumiGiftChallengeSettlement if err := s.db.WithContext(ctx).Select("id"). Where("delivery_status = ? OR (delivery_status = ? AND update_time <= ?)", DeliveryPending, DeliveryProcessing, s.deliveryLeaseCutoff()). Order("update_time ASC, id ASC").Limit(batch).Find(&rows).Error; err != nil { return err } for _, row := range rows { if err := ctx.Err(); err != nil { return err } result.SettlementOwnersScanned++ itemCtx, cancel := context.WithTimeout(ctx, settleDueItemTimeout) err := s.ensureSettlementDeliveryItems(itemCtx, row.ID) if err == nil { err = s.deliverOwnerItems(itemCtx, OwnerSettlement, row.ID) } cancel() if err != nil { result.FailedKeys = append(result.FailedKeys, fmt.Sprintf("SETTLEMENT:%d", row.ID)) continue } result.OwnersProcessed++ } return nil } func normalizeSettleDueBatch(batch int) int { if batch <= 0 { return defaultSettleDueBatch } if batch > maxSettleDueBatch { return maxSettleDueBatch } return batch } func (s *Service) deliveryLeaseCutoff() time.Time { leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds if leaseSeconds < 30 { leaseSeconds = 300 } return time.Now().Add(-time.Duration(leaseSeconds) * time.Second) }