package wallet import ( "context" "encoding/json" "math" "strconv" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。 type ExternalRechargeConfig struct { USDTTRC20Enabled bool USDTTRC20Address string MifaPayNotifyURL string MifaPayReturnURL string } // MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。 type MifaPayCreateOrderRequest struct { OrderID string TargetUserID int64 ProductName string CoinAmount int64 AmountMinor int64 CurrencyCode string CountryCode string PayWay string PayType string NotifyURL string ReturnURL string ClientIP string Language string ProviderAmountMinor int64 } type MifaPayCreateOrderResponse struct { OrderID string ProviderOrderID string PayURL string RawJSON string } type MifaPayNotifyData struct { OrderID string ProviderOrderID string OrderStatus string Amount string Currency string PayType string RawJSON string } // MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节,service 只处理订单状态。 type MifaPayClient interface { CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error) ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error) } // TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。 type TronUSDTClient interface { VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) } func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig { config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address) config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL) config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL) return config } // ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。 func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) if strings.TrimSpace(query.Status) != "" { query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status) if query.Status == "" { return nil, xerr.New(xerr.InvalidArgument, "status is invalid") } } ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.ListThirdPartyPaymentChannels(ctx, query) } func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) { if command.MethodID <= 0 || command.OperatorUserID <= 0 { return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required") } if s.repository == nil { return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) ctx = appcode.WithContext(ctx, command.AppCode) return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command) } func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) { command.AppCode = appcode.Normalize(command.AppCode) command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate) if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) { return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid") } if s.repository == nil { return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, command.AppCode) return s.repository.UpdateThirdPartyPaymentRate(ctx, command) } // ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。 func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) { query.AppCode = appcode.Normalize(query.AppCode) query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode)) if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" { return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete") } if s.repository == nil { return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, query.AppCode) options, err := s.repository.ListH5RechargeOptions(ctx, query) if err != nil { return ledger.H5RechargeOptions{}, err } if s.mifaPay == nil { options.PaymentMethods = nil } options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != "" options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address return options, nil } // CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。 func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) { command = normalizeCreateExternalRechargeOrderCommand(command) if err := validateCreateExternalRechargeOrderCommand(command); err != nil { return ledger.ExternalRechargeOrder{}, err } if s.repository == nil { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, command.AppCode) product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) if err != nil { return ledger.ExternalRechargeOrder{}, err } if err := validateExternalRechargeProduct(product, command); err != nil { return ledger.ExternalRechargeOrder{}, err } switch command.ProviderCode { case ledger.PaymentProviderUSDTTRC20: return s.createUSDTRechargeOrder(ctx, command, product) case ledger.PaymentProviderMifaPay: return s.createMifaPayRechargeOrder(ctx, command, product) default: return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid") } } func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured") } return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address) } func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { if s.mifaPay == nil { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay client is not configured") } method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) if err != nil { return ledger.ExternalRechargeOrder{}, err } if err := validateMifaPayMethodForOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) if err != nil { return ledger.ExternalRechargeOrder{}, err } order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "") if err != nil || order.IdempotentReplay { return order, err } notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.MifaPayNotifyURL) returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.MifaPayReturnURL) if notifyURL == "" { _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "mifapay notify_url is not configured", "") return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay notify_url is not configured") } resp, err := s.mifaPay.CreateOrder(ctx, MifaPayCreateOrderRequest{ OrderID: order.OrderID, TargetUserID: order.TargetUserID, ProductName: order.ProductName, CoinAmount: order.CoinAmount, AmountMinor: order.USDMinorAmount, CurrencyCode: order.CurrencyCode, CountryCode: order.CountryCode, PayWay: order.PayWay, PayType: order.PayType, NotifyURL: notifyURL, ReturnURL: returnURL, ClientIP: command.ClientIP, Language: command.Language, ProviderAmountMinor: providerAmountMinor, }) if err != nil { _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), "") return ledger.ExternalRechargeOrder{}, err } return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) } // SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。 func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) { command.AppCode = appcode.Normalize(command.AppCode) command.OrderID = strings.TrimSpace(command.OrderID) command.TxHash = strings.TrimSpace(command.TxHash) if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete") } if s.repository == nil { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, command.AppCode) order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID) if err != nil { return ledger.ExternalRechargeOrder{}, err } if order.TargetUserID != command.TargetUserID { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") } if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") } if s.tronUSDT == nil { return s.repository.AttachExternalRechargeTx(ctx, command, "") } rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount) if err != nil { _, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON) return ledger.ExternalRechargeOrder{}, err } if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil { return ledger.ExternalRechargeOrder{}, err } return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON) } func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) { if s.repository == nil { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), strings.TrimSpace(orderID)) if err != nil { return ledger.ExternalRechargeOrder{}, err } if targetUserID > 0 && order.TargetUserID != targetUserID { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") } return order, nil } // HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。 func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) { if s.mifaPay == nil { return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured") } if s.repository == nil { return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") } ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) data, err := s.mifaPay.ParseNotification(notification) if err != nil { return ledger.ExternalRechargeOrder{}, false, err } order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID) if err != nil { return ledger.ExternalRechargeOrder{}, false, err } if !strings.EqualFold(data.OrderStatus, "SUCCESS") { order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON) return order, true, err } if !mifaPayNotifyMatchesOrder(data, order) { order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON) return order, false, err } credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON) return credited, err == nil, err } func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand { command.AppCode = appcode.Normalize(command.AppCode) command.CommandID = strings.TrimSpace(command.CommandID) command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)) command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType) command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode) command.ReturnURL = strings.TrimSpace(command.ReturnURL) command.NotifyURL = strings.TrimSpace(command.NotifyURL) command.ClientIP = strings.TrimSpace(command.ClientIP) command.Language = strings.TrimSpace(command.Language) if command.Language == "" { command.Language = "en" } return command } func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error { if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 { return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete") } if command.AudienceType == "" || command.ProviderCode == "" { return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid") } if len(command.CommandID) > 128 { return xerr.New(xerr.InvalidArgument, "command_id is too long") } return nil } func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error { if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { return xerr.New(xerr.Conflict, "recharge product is not active") } if product.Platform != ledger.RechargeProductPlatformWeb { return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product") } if product.AudienceType != command.AudienceType { return xerr.New(xerr.Conflict, "recharge product audience does not match") } if !rechargeProductSupportsRegion(product, command.TargetRegionID) { return xerr.New(xerr.Conflict, "recharge product is not available in user region") } return nil } func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error { if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive { return xerr.New(xerr.Conflict, "mifapay method is not active") } if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { return xerr.New(xerr.Conflict, "mifapay method country does not match") } if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { return err } return nil } func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { if usdMinor <= 0 { return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") } value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) if err != nil || value <= 0 { return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") } return int64(math.Round(float64(usdMinor) * value)), nil } func amountMicroToUSDMinor(amountMicro int64) int64 { if amountMicro <= 0 { return 0 } return amountMicro / 10_000 } func validNonNegativeDecimal(value string) bool { parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) return err == nil && parsed >= 0 } func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { if !strings.EqualFold(data.Currency, order.CurrencyCode) { return false } if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { return false } amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) return err == nil && amount == order.ProviderAmountMinor } func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" } func mustJSONText(value any) string { body, err := json.Marshal(value) if err != nil { return "{}" } return string(body) }