package wheel import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/wheel" ) // RewardWorkerOptions 定义补偿 worker 的租户、锁和重试边界。 type RewardWorkerOptions struct { WorkerID string AppCodes []string PollInterval time.Duration BatchSize int LockTTL time.Duration MaxRetry int } // RewardWorkerBatchResult 只统计本轮 claim 后的终态;失败项已经释放为 retryable/failed,不会静默丢失。 type RewardWorkerBatchResult struct { Claimed int Delivered int Failed int } type wheelRewardOutboxPayload struct { AppCode string `json:"app_code"` DrawID string `json:"draw_id"` DrawIDs []string `json:"draw_ids"` CommandID string `json:"command_id"` UserID int64 `json:"user_id"` WheelID string `json:"wheel_id"` SelectedTierID string `json:"selected_tier_id"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` Rewards []domain.DrawReward `json:"rewards"` VisibleRegionID int64 `json:"visible_region_id"` } // RunRewardWorker 持续逐 App 分区补偿 WheelRewardSettlement。 // AppCode 必须写进 context 后再 claim 和调用钱包,避免后台根 context 被 appcode 默认值悄悄收敛成 lalu。 func (s *Service) RunRewardWorker(ctx context.Context, options RewardWorkerOptions) { options = normalizeRewardWorkerOptions(options) ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() for { if _, err := s.ProcessPendingRewards(ctx, options); err != nil && ctx.Err() == nil { logx.Error(ctx, "wheel_reward_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID)) } select { case <-ctx.Done(): return case <-ticker.C: } } } // ProcessPendingRewards 执行一轮有界补偿。 // 钱包 RPC 使用 draw_id 派生的稳定 command_id;钱包 owner 会在同一事务内先查既有交易/权益及 request hash,命中则返回原收据,未命中才创建,因此 activity 只在收齐所有收据后标 delivered。 func (s *Service) ProcessPendingRewards(ctx context.Context, options RewardWorkerOptions) (RewardWorkerBatchResult, error) { if err := s.requireRepository(); err != nil { return RewardWorkerBatchResult{}, err } options = normalizeRewardWorkerOptions(options) var result RewardWorkerBatchResult var batchErr error for _, appCodeValue := range options.AppCodes { if ctx.Err() != nil { return result, errors.Join(batchErr, ctx.Err()) } appCtx := appcode.WithContext(ctx, appCodeValue) nowMS := s.now().UTC().UnixMilli() records, err := s.repository.ClaimWheelRewardOutbox(appCtx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.BatchSize, options.MaxRetry) if err != nil { batchErr = errors.Join(batchErr, fmt.Errorf("claim app=%s: %w", appCodeValue, err)) continue } result.Claimed += len(records) for _, record := range records { if err := s.processRewardOutboxRecord(appCtx, record, options); err != nil { result.Failed++ batchErr = errors.Join(batchErr, fmt.Errorf("process app=%s outbox=%s: %w", appCodeValue, record.OutboxID, err)) continue } result.Delivered++ } } return result, batchErr } func (s *Service) processRewardOutboxRecord(ctx context.Context, record domain.RewardOutboxRecord, options RewardWorkerOptions) error { payload, err := decodeWheelRewardOutboxPayload(record) if err == nil { var fulfilled domain.DrawResult fulfilled, err = s.fulfillWheelReward(ctx, domain.DrawCommand{ CommandID: payload.CommandID, WheelID: payload.WheelID, UserID: payload.UserID, VisibleRegionID: payload.VisibleRegionID, }, domain.DrawResult{ DrawID: payload.DrawID, DrawIDs: payload.DrawIDs, CommandID: payload.CommandID, WheelID: payload.WheelID, SelectedTierID: payload.SelectedTierID, RewardType: payload.RewardType, RewardID: payload.RewardID, RewardCount: payload.RewardCount, RewardCoins: payload.RewardCoins, RewardStatus: domain.StatusPending, Rewards: payload.Rewards, }) if err == nil { drawIDs := payload.DrawIDs if len(drawIDs) == 0 { drawIDs = []string{payload.DrawID} } // draw、统计和 outbox 的最终提交带 worker fencing token;若 lease 已被接管,这里会回滚而不是覆盖新 worker。 return s.repository.MarkWheelRewardOutboxDelivered(ctx, record.OutboxID, options.WorkerID, drawIDs, fulfilled.WalletTransactionID, s.now().UTC().UnixMilli()) } } nowMS := s.now().UTC().UnixMilli() nextRetryAtMS := nowMS + wheelRewardRetryDelay(record.RetryCount+1).Milliseconds() markErr := s.repository.MarkWheelRewardOutboxFailed(ctx, record.OutboxID, options.WorkerID, nowMS, nextRetryAtMS, options.MaxRetry, err.Error()) if markErr != nil { return errors.Join(err, fmt.Errorf("mark wheel reward retryable: %w", markErr)) } return err } func decodeWheelRewardOutboxPayload(record domain.RewardOutboxRecord) (wheelRewardOutboxPayload, error) { var payload wheelRewardOutboxPayload if err := json.Unmarshal(record.Payload, &payload); err != nil { return payload, fmt.Errorf("decode wheel reward payload: %w", err) } recordApp := appcode.Normalize(record.AppCode) payloadApp := appcode.Normalize(payload.AppCode) if payload.AppCode != "" && payloadApp != recordApp { return payload, xerr.New(xerr.Conflict, "wheel reward payload app_code does not match outbox partition") } payload.AppCode = recordApp payload.DrawID = strings.TrimSpace(payload.DrawID) payload.CommandID = strings.TrimSpace(payload.CommandID) payload.WheelID = strings.TrimSpace(payload.WheelID) if payload.DrawID == "" || payload.CommandID == "" || payload.WheelID == "" || payload.UserID <= 0 { return payload, xerr.New(xerr.InvalidArgument, "wheel reward payload identity is incomplete") } if len(payload.DrawIDs) == 0 { payload.DrawIDs = []string{payload.DrawID} } for index := range payload.DrawIDs { payload.DrawIDs[index] = strings.TrimSpace(payload.DrawIDs[index]) if payload.DrawIDs[index] == "" { return payload, xerr.New(xerr.InvalidArgument, "wheel reward payload contains empty draw_id") } } return payload, nil } func normalizeRewardWorkerOptions(options RewardWorkerOptions) RewardWorkerOptions { options.WorkerID = strings.TrimSpace(options.WorkerID) if options.WorkerID == "" { options.WorkerID = "activity-wheel-reward" } seen := make(map[string]struct{}, len(options.AppCodes)) appCodes := make([]string, 0, len(options.AppCodes)) for _, value := range options.AppCodes { value = appcode.Normalize(value) if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} appCodes = append(appCodes, value) } if len(appCodes) == 0 { appCodes = []string{"lalu", "fami", "huwaa"} } options.AppCodes = appCodes if options.PollInterval <= 0 { options.PollInterval = time.Second } if options.BatchSize <= 0 { options.BatchSize = 50 } if options.LockTTL <= 0 { options.LockTTL = 30 * time.Second } if options.MaxRetry <= 0 { options.MaxRetry = 16 } return options } func wheelRewardRetryDelay(attempt int) time.Duration { if attempt < 1 { attempt = 1 } if attempt > 7 { attempt = 7 } delay := time.Second * time.Duration(1<<(attempt-1)) if delay > time.Minute { return time.Minute } return delay }