package wheel import ( "context" "log/slog" "strings" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/wheel" ) type Repository interface { PublishWheelRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) GetWheelRuleConfig(ctx context.Context, wheelID string) (domain.RuleConfig, bool, error) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) GetWheelDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) MarkWheelDrawsGranted(ctx context.Context, appCode string, drawIDs []string, transactionID string, nowMS int64) error } type WalletClient interface { CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error) } type Service struct { repository Repository wallet WalletClient now func() time.Time } type Option func(*Service) func WithWallet(client WalletClient) Option { return func(s *Service) { s.wallet = client } } func New(repository Repository, options ...Option) *Service { service := &Service{repository: repository, now: time.Now} for _, option := range options { if option != nil { option(service) } } return service } func (s *Service) SetClock(now func() time.Time) { if now != nil { s.now = now } } func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) { if err := s.requireRepository(); err != nil { return domain.DrawResult{}, err } cmd.CommandID = strings.TrimSpace(cmd.CommandID) cmd.WheelID = NormalizeWheelID(cmd.WheelID) cmd.DeviceID = strings.TrimSpace(cmd.DeviceID) if cmd.DrawCount <= 0 { cmd.DrawCount = 1 } if cmd.CommandID == "" || cmd.WheelID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.CoinSpent <= 0 { return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw command is incomplete") } if cmd.PaidAtMS <= 0 { // paid_at_ms 来自钱包扣费事实;缺失时使用 UTC 服务端时间,保证 RTP 窗口和审计时间不受本地时区影响。 cmd.PaidAtMS = s.now().UTC().UnixMilli() } result, err := s.repository.ExecuteWheelDraw(ctx, cmd, s.now().UTC().UnixMilli()) if err != nil { return domain.DrawResult{}, err } return s.creditCoinRewardFastPath(ctx, cmd, result), nil } func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) { if err := s.requireRepository(); err != nil { return domain.RuleConfig{}, err } config = NormalizeRuleConfig(config) if err := ValidateRuleConfig(config); err != nil { return domain.RuleConfig{}, err } return s.repository.PublishWheelRuleConfig(ctx, config, s.now().UTC().UnixMilli()) } func (s *Service) GetConfig(ctx context.Context, wheelID string) (domain.RuleConfig, error) { if err := s.requireRepository(); err != nil { return domain.RuleConfig{}, err } config, exists, err := s.repository.GetWheelRuleConfig(ctx, NormalizeWheelID(wheelID)) if err != nil { return domain.RuleConfig{}, err } if !exists { return domain.RuleConfig{}, xerr.New(xerr.NotFound, "wheel config not found") } return config, nil } func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) { if err := s.requireRepository(); err != nil { return nil, 0, err } return s.repository.ListWheelDraws(ctx, normalizeDrawQuery(query)) } func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { if err := s.requireRepository(); err != nil { return domain.DrawSummary{}, err } return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query)) } func (s *Service) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult { if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 { return result } if s.wallet == nil { logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID)) return result } resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{ CommandId: "wheel_reward:" + result.DrawID, AppCode: appcode.FromContext(ctx), TargetUserId: cmd.UserID, Amount: result.RewardCoins, DrawId: result.DrawID, WheelId: result.WheelID, SelectedTierId: result.SelectedTierID, Reason: "wheel_reward", VisibleRegionId: cmd.VisibleRegionID, }) if err != nil { // 钱包不可用不能回滚已经确认的抽奖事实;pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。 logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID)) return result } result.RewardStatus = domain.StatusGranted result.WalletTransactionID = resp.GetTransactionId() result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount() if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.DrawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil { logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID)) } return result } func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery { query.WheelID = NormalizeWheelID(query.WheelID) query.Status = strings.TrimSpace(query.Status) if query.Page <= 0 { query.Page = 1 } if query.PageSize <= 0 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } return query } func (s *Service) requireRepository() error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "wheel repository is not configured") } return nil }