package wallet import ( "context" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" "strings" ) const temporaryProviderMemberID int64 = 1 // CreateTemporaryRechargeOrder 创建运营临时收款订单;它只校验支付方式和金额,不读取充值商品。 func (s *Service) CreateTemporaryRechargeOrder(ctx context.Context, command ledger.CreateTemporaryExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) { command = normalizeCreateTemporaryExternalRechargeOrderCommand(command) if err := validateCreateTemporaryExternalRechargeOrderCommand(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) switch command.ProviderCode { case ledger.PaymentProviderMifaPay: return s.createTemporaryMifaPayRechargeOrder(ctx, command) case ledger.PaymentProviderV5Pay: return s.createTemporaryV5PayRechargeOrder(ctx, command) default: return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid") } } func (s *Service) createTemporaryMifaPayRechargeOrder(ctx context.Context, command ledger.CreateTemporaryExternalRechargeOrderCommand) (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 := validateMifaPayMethodForTemporaryOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } // 临时链接没有商品档位,三方实付金额只能由运营输入的 USD 金额和支付方式汇率即时换算;订单快照会保存换算结果。 providerAmountMinor, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } // 先写 pending 订单再调三方,保证三方回调先于 HTTP 响应返回时也能按 order_id 找到本地订单。 order, err := s.repository.CreateTemporaryExternalRechargeOrder(ctx, command, method, providerAmountMinor) if err != nil || order.IdempotentReplay { return order, err } // notify_url 是支付状态闭环的必要条件;缺配置时把订单打失败,避免运营拿到一个无法回调核销的链接。 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: temporaryProviderMemberID, ProductName: order.ProductName, CoinAmount: order.CoinAmount, AmountMinor: order.USDMinorAmount, CurrencyCode: order.CurrencyCode, CountryCode: order.CountryCode, PayWay: order.PayWay, PayType: order.PayType, NotifyURL: notifyURL, ReturnURL: mifaPayProviderReturnURL(returnURL), ClientIP: command.ClientIP, Language: command.Language, ProviderAmountMinor: providerAmountMinor, PayerName: command.PayerName, PayerAccount: command.PayerAccount, PayerEmail: command.PayerEmail, }) if err != nil { _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err)) return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err) } return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) } func (s *Service) createTemporaryV5PayRechargeOrder(ctx context.Context, command ledger.CreateTemporaryExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) { if s.v5Pay == nil { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) if err != nil { return ledger.ExternalRechargeOrder{}, err } if err := validateV5PayMethodForTemporaryOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } // V5Pay 同样以支付方式汇率换算本币金额;临时订单不读取 wallet_recharge_products,避免误套正式商品受众。 providerAmountMinor, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } // 本地订单先落库,三方创建失败再转 failed;这样人工排查时能看到每次临时建链尝试。 order, err := s.repository.CreateTemporaryExternalRechargeOrder(ctx, command, method, providerAmountMinor) if err != nil || order.IdempotentReplay { return order, err } notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL) returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL) if notifyURL == "" { _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "") return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured") } resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{ OrderID: order.OrderID, TargetUserID: temporaryProviderMemberID, ProductName: order.ProductName, CoinAmount: order.CoinAmount, AmountMinor: order.USDMinorAmount, CurrencyCode: order.CurrencyCode, CountryCode: order.CountryCode, ProductType: order.PayType, NotifyURL: notifyURL, ReturnURL: v5PayProviderReturnURL(returnURL), Language: command.Language, ProviderAmountMinor: providerAmountMinor, PayerName: command.PayerName, PayerAccount: command.PayerAccount, PayerEmail: command.PayerEmail, }) if err != nil { _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), providerPayloadJSON(err)) return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay") } return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) } func (s *Service) GetTemporaryRechargeOrder(ctx context.Context, appCode string, orderID string) (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 !isTemporaryExternalRechargeOrder(order) { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "temporary recharge order not found") } // 单笔查询被 H5 验证框和 admin 页面共用;这里主动刷新三方状态,但刷新失败不盖掉本地快照,避免短暂上游错误让后台看不到订单。 if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil { order = refreshed } if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil { order = refreshed } return order, nil } func (s *Service) ListTemporaryRechargeOrders(ctx context.Context, query ledger.ListTemporaryExternalRechargeOrdersQuery) ([]ledger.ExternalRechargeOrder, int64, error) { if s.repository == nil { return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") } query.AppCode = appcode.Normalize(query.AppCode) query.ProviderCode = ledger.NormalizePaymentProvider(query.ProviderCode) query.Status = strings.TrimSpace(query.Status) query.Keyword = strings.TrimSpace(query.Keyword) ctx = appcode.WithContext(ctx, query.AppCode) return s.repository.ListTemporaryExternalRechargeOrders(ctx, query) } func normalizeCreateTemporaryExternalRechargeOrderCommand(command ledger.CreateTemporaryExternalRechargeOrderCommand) ledger.CreateTemporaryExternalRechargeOrderCommand { command.AppCode = appcode.Normalize(command.AppCode) command.CommandID = strings.TrimSpace(command.CommandID) 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 = normalizeMifaPayLanguage(command.Language) command.PayerName = strings.TrimSpace(command.PayerName) command.PayerAccount = strings.TrimSpace(command.PayerAccount) command.PayerEmail = strings.TrimSpace(command.PayerEmail) return command } func validateCreateTemporaryExternalRechargeOrderCommand(command ledger.CreateTemporaryExternalRechargeOrderCommand) error { if command.CommandID == "" || command.USDMinorAmount <= 0 || command.PaymentMethodID <= 0 { return xerr.New(xerr.InvalidArgument, "temporary recharge command is incomplete") } // 临时支付链接只记录三方收款事实,不会触发用户金币入账;负数没有业务含义,0 用于表达“不发币”。 if command.CoinAmount < 0 { return xerr.New(xerr.InvalidArgument, "temporary recharge coin amount is invalid") } if command.ProviderCode == "" { return xerr.New(xerr.InvalidArgument, "temporary recharge provider is invalid") } if len(command.CommandID) > 128 { return xerr.New(xerr.InvalidArgument, "command_id is too long") } return nil } func validateMifaPayMethodForTemporaryOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateTemporaryExternalRechargeOrderCommand) error { if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive || method.MethodID != command.PaymentMethodID { return xerr.New(xerr.Conflict, "mifapay method is not active") } if _, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil } func validateV5PayMethodForTemporaryOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateTemporaryExternalRechargeOrderCommand) error { if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive || method.MethodID != command.PaymentMethodID { return xerr.New(xerr.Conflict, "v5pay method is not active") } if strings.TrimSpace(method.PayType) == "" { return xerr.New(xerr.InvalidArgument, "v5pay product type is required") } if _, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil } func isTemporaryExternalRechargeOrder(order ledger.ExternalRechargeOrder) bool { return order.AudienceType == ledger.RechargeAudienceTemporary || strings.HasPrefix(order.CommandID, "h5_temporary_recharge:") }