package wallet import ( "context" "log/slog" "sync" "time" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" "strings" ) // ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。 func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) { if s.repository == nil { return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.ListRechargeBills(ctx, query) } // GetRechargeBillSummary 按与账单列表一致的筛选口径聚合充值总和、Google 充值与三方充值。 func (s *Service) GetRechargeBillSummary(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) { if s.repository == nil { return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.SummarizeRechargeBills(ctx, query) } // BatchGetUserRechargeStats 返回后台详情需要的累计充值聚合;缺失用户不返回占位行,由调用方按 0 展示。 func (s *Service) BatchGetUserRechargeStats(ctx context.Context, appCode string, userIDs []int64) ([]ledger.UserRechargeStats, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") } appCode = appcode.Normalize(appCode) ctx = appcode.WithContext(ctx, appCode) return s.repository.BatchGetUserRechargeStats(ctx, userIDs) } // GetRechargeBillOverview 汇总财务概览的按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。 func (s *Service) GetRechargeBillOverview(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMinutes int32) (ledger.RechargeBillOverview, error) { if s.repository == nil { return ledger.RechargeBillOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.GetRechargeBillOverview(ctx, query, tzOffsetMinutes) } // GooglePaymentPriceResult 是单笔 Google 账单实付明细刷新结果;Err 非空表示该笔刷新失败但不影响其他账单。 type GooglePaymentPriceResult struct { TransactionID string CurrencyCode string PaidAmountMicro int64 TaxAmountMicro int64 NetAmountMicro int64 SyncedAtMS int64 Err string } type GooglePaymentPaidSyncBatchResult struct { Claimed int Succeeded int Failed int } type googlePlayOrdersBatchClient interface { GetOrders(ctx context.Context, packageName string, orderIDs []string) (map[string]ledger.GooglePlayOrder, error) } const maxGooglePaymentPriceRefreshBatch = 100 // RefreshGooglePaymentPrices 用 Play Orders API 拉取账单的用户实付币种、总额、税额和净收入并写回 payment_orders。 // 服务账号需要 Play Console 的“查看财务数据”权限;单笔失败只标记该笔的 Err,整批继续。 func (s *Service) RefreshGooglePaymentPrices(ctx context.Context, appCode string, transactionIDs []string) ([]GooglePaymentPriceResult, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") } if s.googlePlay == nil { return nil, xerr.New(xerr.Unavailable, "google play client is not configured") } if len(transactionIDs) == 0 { return []GooglePaymentPriceResult{}, nil } if len(transactionIDs) > maxGooglePaymentPriceRefreshBatch { return nil, xerr.New(xerr.InvalidArgument, "too many transaction_ids") } appCode = appcode.Normalize(appCode) ctx = appcode.WithContext(ctx, appCode) refs, err := s.repository.ListGooglePaymentOrderRefs(ctx, appCode, transactionIDs) if err != nil { return nil, err } results := make([]GooglePaymentPriceResult, 0, len(transactionIDs)) for _, transactionID := range transactionIDs { transactionID = strings.TrimSpace(transactionID) if transactionID == "" { continue } ref, ok := refs[transactionID] if !ok { results = append(results, GooglePaymentPriceResult{TransactionID: transactionID, Err: "not a google payment bill"}) continue } result := s.refreshGooglePaymentPrice(ctx, appCode, ref) results = append(results, result) } return results, nil } func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef) GooglePaymentPriceResult { if strings.TrimSpace(ref.ProviderOrderID) == "" { return s.failedGooglePaymentPrice(ctx, ref, "google order id is missing") } order, err := s.googlePlay.GetOrder(ctx, ref.PackageName, ref.ProviderOrderID) if err != nil { return s.failedGooglePaymentPrice(ctx, ref, err.Error()) } return s.refreshGooglePaymentPriceFromOrder(ctx, appCode, ref, order) } func (s *Service) refreshGooglePaymentPriceFromOrder(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef, order ledger.GooglePlayOrder) GooglePaymentPriceResult { result := GooglePaymentPriceResult{TransactionID: ref.TransactionID} details := ledger.GooglePaymentPaidDetails{ CurrencyCode: order.CurrencyCode, PaidAmountMicro: order.TotalAmountMicro, TaxAmountMicro: order.TaxAmountMicro, NetAmountMicro: order.NetAmountMicro, SyncedAtMS: s.now().UnixMilli(), } if err := s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, ref.PaymentOrderID, details); err != nil { return s.failedGooglePaymentPrice(ctx, ref, err.Error()) } result.CurrencyCode = details.CurrencyCode result.PaidAmountMicro = details.PaidAmountMicro result.TaxAmountMicro = details.TaxAmountMicro result.NetAmountMicro = details.NetAmountMicro result.SyncedAtMS = details.SyncedAtMS return result } func (s *Service) failedGooglePaymentPrice(ctx context.Context, ref ledger.GooglePaymentOrderRef, message string) GooglePaymentPriceResult { result := GooglePaymentPriceResult{TransactionID: ref.TransactionID, Err: strings.TrimSpace(message)} if err := s.repository.MarkGooglePaymentPaidSyncFailure(ctx, ref, result.Err, s.now().UnixMilli()); err != nil { logx.Warn(ctx, "google_paid_sync_failure_persist_failed", slog.String("app_code", ref.AppCode), slog.String("payment_order_id", ref.PaymentOrderID), slog.String("error", err.Error()), ) } return result } // ProcessGooglePaymentPaidSyncBatch is the durable worker entrypoint. It uses batchGet for the common // path and bounded single-order fallback when Google rejects a batch containing one stale order ID. func (s *Service) ProcessGooglePaymentPaidSyncBatch(ctx context.Context, batchSize int) (GooglePaymentPaidSyncBatchResult, error) { if s.repository == nil { return GooglePaymentPaidSyncBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } if s.googlePlay == nil { return GooglePaymentPaidSyncBatchResult{}, xerr.New(xerr.Unavailable, "google play client is not configured") } refs, err := s.repository.ListPendingGooglePaymentOrderRefs(ctx, batchSize, s.now().UnixMilli()) if err != nil { return GooglePaymentPaidSyncBatchResult{}, err } result := GooglePaymentPaidSyncBatchResult{Claimed: len(refs)} if len(refs) == 0 { return result, nil } prices := s.refreshGooglePaymentRefBatch(ctx, refs) for _, price := range prices { if price.Err == "" { result.Succeeded++ } else { result.Failed++ } } return result, nil } func (s *Service) refreshGooglePaymentRefBatch(ctx context.Context, refs []ledger.GooglePaymentOrderRef) []GooglePaymentPriceResult { results := make([]GooglePaymentPriceResult, len(refs)) groups := make(map[string][]int) for index, ref := range refs { groups[strings.TrimSpace(ref.PackageName)] = append(groups[strings.TrimSpace(ref.PackageName)], index) } batchClient, supportsBatch := s.googlePlay.(googlePlayOrdersBatchClient) for packageName, indexes := range groups { if supportsBatch && packageName != "" { orderIDs := make([]string, 0, len(indexes)) for _, index := range indexes { if orderID := strings.TrimSpace(refs[index].ProviderOrderID); orderID != "" { orderIDs = append(orderIDs, orderID) } } if len(orderIDs) == len(indexes) { if orders, err := batchClient.GetOrders(ctx, packageName, orderIDs); err == nil { for _, index := range indexes { ref := refs[index] order, ok := orders[ref.ProviderOrderID] if !ok { results[index] = s.failedGooglePaymentPrice(ctx, ref, "google order is not found") continue } results[index] = s.refreshGooglePaymentPriceFromOrder(ctx, ref.AppCode, ref, order) } continue } } } s.refreshGooglePaymentRefSingles(ctx, refs, indexes, results) } return results } func (s *Service) refreshGooglePaymentRefSingles(ctx context.Context, refs []ledger.GooglePaymentOrderRef, indexes []int, results []GooglePaymentPriceResult) { const concurrency = 8 semaphore := make(chan struct{}, concurrency) var wait sync.WaitGroup for _, index := range indexes { wait.Add(1) go func(index int) { defer wait.Done() select { case semaphore <- struct{}{}: defer func() { <-semaphore }() case <-ctx.Done(): results[index] = s.failedGooglePaymentPrice(ctx, refs[index], ctx.Err().Error()) return } results[index] = s.refreshGooglePaymentPrice(ctx, refs[index].AppCode, refs[index]) }(index) } wait.Wait() } // ListRechargeProducts 返回区域化充值档位;region_id 必须由 gateway 从 user-service 资料解析。 func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) { if userID <= 0 || regionID <= 0 { return nil, nil, xerr.New(xerr.InvalidArgument, "user_id and region_id are required") } if strings.TrimSpace(platform) != "" { platform = ledger.NormalizeRechargeProductPlatform(platform) if platform == "" { return nil, nil, xerr.New(xerr.InvalidArgument, "platform is invalid") } } audienceType = ledger.NormalizeRechargeAudienceType(audienceType) if audienceType == "" { return nil, nil, xerr.New(xerr.InvalidArgument, "audience_type is invalid") } if s.repository == nil { return nil, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) return s.repository.ListRechargeProducts(ctx, userID, regionID, platform, audienceType) } // ListAdminRechargeProducts 返回后台配置列表;后台入口负责 RBAC 和审计。 func (s *Service) ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) { if s.repository == nil { return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) if strings.TrimSpace(query.Status) != "" { query.Status = ledger.NormalizeRechargeProductStatus(query.Status) if query.Status == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "status is invalid") } } if strings.TrimSpace(query.Platform) != "" { query.Platform = ledger.NormalizeRechargeProductPlatform(query.Platform) if query.Platform == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "platform is invalid") } } if strings.TrimSpace(query.AudienceType) != "" { query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) if query.AudienceType == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "audience_type is invalid") } } ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.ListAdminRechargeProducts(ctx, query) } // CreateRechargeProduct 创建内购商品配置;首版充值资源只允许发 COIN。 func (s *Service) CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { if s.repository == nil { return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) if err := validateRechargeProductCommand(command, false); err != nil { return ledger.RechargeProduct{}, err } ctx = appcode.WithContext(ctx, command.AppCode) return s.repository.CreateRechargeProduct(ctx, command) } // UpdateRechargeProduct 覆盖内购商品配置和支持区域,避免状态散落在后台库。 func (s *Service) UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { if s.repository == nil { return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) if err := validateRechargeProductCommand(command, true); err != nil { return ledger.RechargeProduct{}, err } ctx = appcode.WithContext(ctx, command.AppCode) return s.repository.UpdateRechargeProduct(ctx, command) } // DeleteRechargeProduct 删除尚未接入 provider order 的配置事实;订单实现后这里需要先做引用检查。 func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error { if productID <= 0 { return xerr.New(xerr.InvalidArgument, "product_id is required") } if s.repository == nil { return xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID) } // ConfirmGooglePayment 校验 Google Play purchase token,原子入账并记录支付审计。 func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) { command.AppCode = appcode.Normalize(command.AppCode) command.CommandID = strings.TrimSpace(command.CommandID) command.ProductCode = strings.TrimSpace(command.ProductCode) command.PackageName = strings.TrimSpace(command.PackageName) command.PurchaseToken = strings.TrimSpace(command.PurchaseToken) command.OrderID = strings.TrimSpace(command.OrderID) if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.PackageName == "" || command.PurchaseToken == "" || (command.ProductID <= 0 && command.ProductCode == "") { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete") } if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long") } if s.repository == nil { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } if s.googlePlay == nil { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured") } ctx = appcode.WithContext(ctx, command.AppCode) product, err := s.googleRechargeProductForPayment(ctx, command) if err != nil { return ledger.GooglePaymentReceipt{}, err } if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active") } if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product") } if command.ProductCode != "" && command.ProductCode != product.ProductName && command.ProductCode != product.ProductCode { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match") } if !rechargeProductSupportsRegion(product, command.RegionID) { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region") } purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken) if err != nil { return ledger.GooglePaymentReceipt{}, err } if purchase.PackageName == "" { purchase.PackageName = command.PackageName } if purchase.PackageName != command.PackageName { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match") } if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased") } // 旧 App 首充曾把钱包内部 product_code 当成确认参数;上面的兼容只负责放行历史入参, // 真正的 Google 商品事实仍以 purchase.ProductID 对齐 product_name,避免内部编码绕过支付商品校验。 if purchase.ProductID != "" && purchase.ProductID != product.ProductName { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product") } if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID { return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match") } receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase) if err != nil { return ledger.GooglePaymentReceipt{}, err } // 入账已完成,实付明细只影响财务展示:先即时异步拉取;失败会持久化并由 15 秒 worker 补偿。 s.scheduleGooglePaymentPriceRefresh(command.AppCode, receipt.PaymentOrderID, command.PackageName, purchase.OrderID) if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed { return receipt, nil } consumeProductID := product.ProductCode if purchase.ProductID != "" { consumeProductID = purchase.ProductID } if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil { return receipt, nil } if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil { return receipt, nil } receipt.ConsumeState = ledger.PaymentConsumeStateConsumed return receipt, nil } // scheduleGooglePaymentPriceRefresh 在支付确认返回后异步同步实付明细,不给支付主链路增加延迟或失败面。 func (s *Service) scheduleGooglePaymentPriceRefresh(appCode string, paymentOrderID string, packageName string, orderID string) { if s.googlePlay == nil || s.repository == nil || strings.TrimSpace(paymentOrderID) == "" || strings.TrimSpace(orderID) == "" { return } go func() { ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appCode), 10*time.Second) defer cancel() result := s.refreshGooglePaymentPrice(ctx, appCode, ledger.GooglePaymentOrderRef{ AppCode: appCode, PaymentOrderID: paymentOrderID, PackageName: packageName, ProviderOrderID: orderID, }) if result.Err != "" { logx.Warn(ctx, "google_paid_sync_immediate_failed", slog.String("app_code", appCode), slog.String("payment_order_id", paymentOrderID), slog.String("error", result.Err), ) } }() } func (s *Service) googleRechargeProductForPayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.RechargeProduct, error) { if command.ProductID > 0 { return s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) } // 首充弹窗只持有 Google 商品 ID,不再依赖 App 充值商品列表返回 product_id; // 这里仍然回到钱包内部商品配置取币数、金额和区域,避免客户端自报充值规格。 return s.repository.GetGoogleRechargeProductByCode(ctx, command.AppCode, command.ProductCode) } func validateRechargeProductCommand(command ledger.RechargeProductCommand, requireProductID bool) error { if requireProductID && command.ProductID <= 0 { return xerr.New(xerr.InvalidArgument, "product_id is required") } // 隐藏的 Google 首充壳商品可以只承载支付金额和 SKU,不直接给普通金币; // 是否发首充资源组由 activity-service 根据 WalletRechargeRecorded 的 google_product_id 决定。 if command.AmountMicro <= 0 || command.CoinAmount < 0 || command.OperatorUserID <= 0 { return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required") } if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 { return xerr.New(xerr.InvalidArgument, "product_name is invalid") } if len([]rune(strings.TrimSpace(command.Description))) > 512 { return xerr.New(xerr.InvalidArgument, "description is too long") } if ledger.NormalizeRechargeProductPlatform(command.Platform) == "" { return xerr.New(xerr.InvalidArgument, "platform is invalid") } if ledger.NormalizeRechargeAudienceType(command.AudienceType) == "" { return xerr.New(xerr.InvalidArgument, "audience_type is invalid") } if len(command.RegionIDs) == 0 { return xerr.New(xerr.InvalidArgument, "region_ids are required") } seen := make(map[int64]struct{}, len(command.RegionIDs)) for _, regionID := range command.RegionIDs { if regionID <= 0 { return xerr.New(xerr.InvalidArgument, "region_id is invalid") } if _, ok := seen[regionID]; ok { return xerr.New(xerr.InvalidArgument, "region_id is duplicated") } seen[regionID] = struct{}{} } return nil } func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool { for _, productRegionID := range product.RegionIDs { if productRegionID == regionID { return true } } return false }