package firstrecharge import ( "context" "sort" "strings" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/firstrecharge" ) const ( grantSource = "first_recharge_reward" grantReason = "first_recharge_reward" ) // Repository 是首冲奖励配置、claim 幂等和发放事实的唯一持久化边界。 type Repository interface { GetFirstRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error) UpdateFirstRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) ListFirstRechargeRewardClaimsByUser(ctx context.Context, userID int64) ([]domain.Claim, error) PrepareFirstRechargeRewardClaim(ctx context.Context, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error) MarkFirstRechargeRewardClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error) MarkFirstRechargeRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error ListFirstRechargeRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) } // WalletClient 是首冲奖励唯一外部副作用;资源组展开由 wallet-service 原子完成。 type WalletClient interface { GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) } // Service 承载首冲奖励 App 查询、后台配置和充值事实消费用例。 type Service struct { repository Repository wallet WalletClient now func() time.Time } func New(repository Repository, wallet WalletClient) *Service { return &Service{repository: repository, wallet: wallet, now: time.Now} } func (s *Service) SetClock(now func() time.Time) { if now != nil { s.now = now } } // GetStatus 返回 App 可展示的当前配置和用户各档位发放状态;未配置时不是错误。 func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusResult, error) { if err := s.requireRepository(); err != nil { return domain.StatusResult{}, err } if userID <= 0 { return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required") } nowMS := s.now().UnixMilli() result := domain.StatusResult{ServerTimeMS: nowMS} config, exists, err := s.repository.GetFirstRechargeRewardConfig(ctx) if err != nil { return domain.StatusResult{}, err } active := []domain.Tier{} if exists { result.Enabled = config.Enabled active = activeTiers(config.Tiers) } claims, err := s.repository.ListFirstRechargeRewardClaimsByUser(ctx, userID) if err != nil { return domain.StatusResult{}, err } result.Claims = claims if len(claims) > 0 { result.Claim = claims[0] } claimedTierIDs := grantedFirstRechargeTierIDs(claims) result.Tiers = remainingFirstRechargeTiers(active, claimedTierIDs) result.Rewarded = len(active) > 0 && len(result.Tiers) == 0 return result, nil } func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } config, exists, err := s.repository.GetFirstRechargeRewardConfig(ctx) if err != nil { return domain.Config{}, err } if !exists { return domain.Config{AppCode: appcode.FromContext(ctx)}, nil } return config, nil } func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } config.AppCode = appcode.FromContext(ctx) config.Tiers = normalizeTiers(config.Tiers) if err := validateConfig(config); err != nil { return domain.Config{}, err } return s.repository.UpdateFirstRechargeRewardConfig(ctx, config, s.now().UnixMilli()) } // Consume 处理 wallet 成功充值事实;只有 Google 商品 ID 命中启用档位才发奖。 func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) (domain.Claim, bool, string, error) { if err := s.requireRepository(); err != nil { return domain.Claim{}, false, "", err } event = normalizeRechargeEvent(event) if err := validateRechargeEvent(event); err != nil { return domain.Claim{}, false, "", err } prepared, err := s.repository.PrepareFirstRechargeRewardClaim(ctx, event, s.now().UnixMilli()) if err != nil { return domain.Claim{}, false, "", err } if prepared.AlreadyGranted { return prepared.Claim, true, prepared.Reason, nil } if !prepared.Prepared { return prepared.Claim, false, prepared.Reason, nil } if s.wallet == nil { return domain.Claim{}, false, "", xerr.New(xerr.Unavailable, "wallet client is not configured") } claim := prepared.Claim resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{ CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID, GroupId: claim.ResourceGroupID, Reason: grantReason, OperatorUserId: claim.UserID, GrantSource: grantSource, }) if err != nil { _ = s.repository.MarkFirstRechargeRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli()) return domain.Claim{}, false, "", err } walletGrantID := "" if resp.GetGrant() != nil { walletGrantID = resp.GetGrant().GetGrantId() } claim, err = s.repository.MarkFirstRechargeRewardClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UnixMilli()) if err != nil { return domain.Claim{}, false, "", err } return claim, true, domain.ReasonEligible, nil } func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) { if err := s.requireRepository(); err != nil { return nil, 0, err } 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 s.repository.ListFirstRechargeRewardClaims(ctx, query) } // ConsumeWalletRechargeEvent handles one MQ-delivered wallet recharge fact. func (s *Service) ConsumeWalletRechargeEvent(ctx context.Context, event domain.RechargeEvent) error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "first recharge reward repository is not configured") } if event.EventType != domain.RechargeEventWalletRecorded { return nil } _, _, _, err := s.Consume(appcode.WithContext(ctx, event.AppCode), event) return err } func (s *Service) requireRepository() error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "first recharge reward repository is not configured") } return nil } func normalizeTiers(tiers []domain.Tier) []domain.Tier { items := make([]domain.Tier, 0, len(tiers)) for _, tier := range tiers { status := strings.ToLower(strings.TrimSpace(tier.Status)) if status == "" { status = domain.TierStatusActive } items = append(items, domain.Tier{ TierID: tier.TierID, TierCode: strings.TrimSpace(tier.TierCode), TierName: strings.TrimSpace(tier.TierName), MinCoinAmount: tier.MinCoinAmount, MaxCoinAmount: tier.MaxCoinAmount, ResourceGroupID: tier.ResourceGroupID, Status: status, SortOrder: tier.SortOrder, USDMinorAmount: tier.USDMinorAmount, GoogleProductID: strings.TrimSpace(tier.GoogleProductID), DiscountPercent: tier.DiscountPercent, }) } sort.SliceStable(items, func(i, j int) bool { if items[i].SortOrder == items[j].SortOrder { return items[i].USDMinorAmount < items[j].USDMinorAmount } return items[i].SortOrder < items[j].SortOrder }) return items } func validateConfig(config domain.Config) error { if config.UpdatedByAdminID <= 0 { return xerr.New(xerr.InvalidArgument, "operator_admin_id is required") } seen := make(map[string]struct{}, len(config.Tiers)) seenGoogleProducts := make(map[string]struct{}, len(config.Tiers)) active := make([]domain.Tier, 0, len(config.Tiers)) for _, tier := range config.Tiers { if tier.TierCode == "" { return xerr.New(xerr.InvalidArgument, "tier_code is required") } if _, exists := seen[tier.TierCode]; exists { return xerr.New(xerr.InvalidArgument, "tier_code is duplicated") } seen[tier.TierCode] = struct{}{} if tier.TierName == "" { return xerr.New(xerr.InvalidArgument, "tier_name is required") } if tier.USDMinorAmount <= 0 { return xerr.New(xerr.InvalidArgument, "usd_minor_amount must be greater than zero") } googleProductID := strings.ToLower(strings.TrimSpace(tier.GoogleProductID)) if googleProductID == "" { return xerr.New(xerr.InvalidArgument, "google_product_id is required") } if _, exists := seenGoogleProducts[googleProductID]; exists { return xerr.New(xerr.InvalidArgument, "google_product_id is duplicated") } seenGoogleProducts[googleProductID] = struct{}{} if tier.DiscountPercent < 0 || tier.DiscountPercent > 100 { return xerr.New(xerr.InvalidArgument, "discount_percent must be between 0 and 100") } if tier.ResourceGroupID <= 0 { return xerr.New(xerr.InvalidArgument, "resource_group_id is required") } switch tier.Status { case domain.TierStatusActive: active = append(active, tier) case domain.TierStatusInactive: default: return xerr.New(xerr.InvalidArgument, "tier status is invalid") } } if config.Enabled && len(active) == 0 { return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier") } return nil } func activeTiers(tiers []domain.Tier) []domain.Tier { items := make([]domain.Tier, 0, len(tiers)) for _, tier := range tiers { if tier.Status == domain.TierStatusActive { items = append(items, tier) } } return items } func grantedFirstRechargeTierIDs(claims []domain.Claim) map[int64]struct{} { ids := make(map[int64]struct{}, len(claims)) for _, claim := range claims { if claim.TierID > 0 && claim.Status == domain.StatusGranted { ids[claim.TierID] = struct{}{} } } return ids } func remainingFirstRechargeTiers(tiers []domain.Tier, granted map[int64]struct{}) []domain.Tier { items := make([]domain.Tier, 0, len(tiers)) for _, tier := range tiers { if _, ok := granted[tier.TierID]; ok { continue } items = append(items, tier) } return items } func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent { event.AppCode = appcode.Normalize(event.AppCode) event.EventID = strings.TrimSpace(event.EventID) event.EventType = strings.TrimSpace(event.EventType) event.TransactionID = strings.TrimSpace(event.TransactionID) event.CommandID = strings.TrimSpace(event.CommandID) event.RechargeType = strings.TrimSpace(event.RechargeType) event.GoogleProductID = strings.TrimSpace(event.GoogleProductID) if event.RechargeType == "" { event.RechargeType = domain.RechargeTypeCoinSeller } return event } func validateRechargeEvent(event domain.RechargeEvent) error { if event.EventID == "" || event.TransactionID == "" || event.CommandID == "" || event.UserID <= 0 { return xerr.New(xerr.InvalidArgument, "first recharge event is incomplete") } if event.EventType != "" && event.EventType != domain.RechargeEventWalletRecorded { return xerr.New(xerr.InvalidArgument, "first recharge event type is invalid") } if event.GoogleProductID == "" { return xerr.New(xerr.InvalidArgument, "google_product_id is required") } // Google 首充档位按 google_product_id 命中,wallet 商品金币可为 0; // 这让后台能保留 Google 验单壳 SKU,同时避免重复给普通金币。 if event.RechargeCoinAmount < 0 { return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must not be negative") } if event.OccurredAtMS <= 0 { return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required") } return nil }