package wallet import ( "context" "strconv" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/service/wallet/ports" ) const ( mifaPayReceiptOrderDateLookbackDays = 30 v5PayReceiptCandidateLimit = 32 ) // VerifyCoinSellerRechargeReceipt 只读取三方支付商或链上公开数据,给运营返回可复核凭证快照。 func (s *Service) VerifyCoinSellerRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) { command = normalizeVerifyCoinSellerRechargeReceiptCommand(command) if err := validateVerifyCoinSellerRechargeReceiptCommand(command); err != nil { return ledger.CoinSellerRechargeReceiptVerification{}, err } ctx = appcode.WithContext(ctx, command.AppCode) switch command.ProviderCode { case ledger.PaymentProviderMifaPay: return s.verifyMifaPayRechargeReceipt(ctx, command) case ledger.PaymentProviderV5Pay: return s.verifyV5PayRechargeReceipt(ctx, command) case ledger.PaymentProviderUSDTTRC20: return s.verifyUSDTRechargeReceipt(ctx, command, "trc20", s.externalRecharge.USDTTRC20Enabled, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode), s.tronUSDT) case ledger.PaymentProviderUSDTBEP20: return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode), s.bscUSDT) default: return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "receipt provider is invalid") } } func normalizeVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSellerRechargeReceiptCommand) ledger.VerifyCoinSellerRechargeReceiptCommand { command.AppCode = appcode.Normalize(command.AppCode) command.RequestID = strings.TrimSpace(command.RequestID) command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode) command.ExternalOrderNo = strings.TrimSpace(command.ExternalOrderNo) command.Chain = normalizeReceiptChain(command.Chain) command.ProviderCountryCode = strings.ToUpper(strings.TrimSpace(command.ProviderCountryCode)) command.ProviderCurrencyCode = strings.ToUpper(strings.TrimSpace(command.ProviderCurrencyCode)) command.ProviderPayType = strings.TrimSpace(command.ProviderPayType) return command } func validateVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSellerRechargeReceiptCommand) error { if command.ProviderCode == "" || command.ExternalOrderNo == "" { return xerr.New(xerr.InvalidArgument, "receipt verification request is incomplete") } if len(command.ExternalOrderNo) > 128 { return xerr.New(xerr.InvalidArgument, "external_order_no is too long") } switch command.ProviderCode { case ledger.PaymentProviderUSDTTRC20, ledger.PaymentProviderUSDTBEP20: if command.USDMinorAmount <= 0 { return xerr.New(xerr.InvalidArgument, "usdt receipt amount is invalid") } } return nil } func (s *Service) verifyMifaPayRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) { if s.mifaPay == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "mifapay client is not configured") } snapshot := s.receiptSnapshot(command) orderDateMS := command.OrderDateMS if orderDateMS <= 0 { // MiFaPay 查单需要订单日期;后台新流程不让运营填写日期,因此优先用本地支付订单快照补齐。 orderDateMS = s.externalRechargeOrderCreatedAtMS(ctx, command.AppCode, command.ExternalOrderNo, ledger.PaymentProviderMifaPay) } var lastNotFound string for _, orderCreatedAtMS := range s.mifaPayReceiptOrderDateCandidates(orderDateMS) { query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{ OrderID: command.ExternalOrderNo, OrderCreatedAtMS: orderCreatedAtMS, }) if err != nil { if providerOrderNotFound(err) { lastNotFound = err.Error() continue } // 签名、HTTP、网络或未知 provider 拒绝都直接返回给后台;这些错误不能被日期回查吞掉。 snapshot.FailureReason = err.Error() return snapshot, nil } return fillMifaPayReceiptSnapshot(snapshot, command, query), nil } if orderDateMS > 0 { snapshot.FailureReason = firstNonEmpty(lastNotFound, "mifapay order not found") } else { snapshot.FailureReason = "mifapay order not found in recent 30 days" } return snapshot, nil } func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerification, command ledger.VerifyCoinSellerRechargeReceiptCommand, query MifaPayQueryOrderResponse) ledger.CoinSellerRechargeReceiptVerification { snapshot.ProviderOrderID = strings.TrimSpace(query.ProviderOrderID) snapshot.Status = strings.TrimSpace(query.Status) snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency)) snapshot.RawJSON = strings.TrimSpace(query.RawJSON) if amount, err := strconv.ParseInt(strings.TrimSpace(query.Amount), 10, 64); err == nil { snapshot.ProviderAmountMinor = amount } if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) { snapshot.FailureReason = "mifapay order id mismatch" return snapshot } if !isMifaPayReceiptPaid(query.Status) { snapshot.FailureReason = "mifapay order status " + firstNonEmpty(strings.TrimSpace(query.Status), "unknown") return snapshot } if command.ProviderCurrencyCode != "" && snapshot.CurrencyCode != "" && !strings.EqualFold(snapshot.CurrencyCode, command.ProviderCurrencyCode) { snapshot.FailureReason = "mifapay currency mismatch" return snapshot } if command.ProviderPayType != "" && query.PayType != "" && !strings.EqualFold(query.PayType, command.ProviderPayType) { snapshot.FailureReason = "mifapay pay_type mismatch" return snapshot } snapshot.Verified = true return snapshot } func (s *Service) mifaPayReceiptOrderDateCandidates(orderDateMS int64) []int64 { if orderDateMS > 0 { return []int64{orderDateMS} } // 本地没有快照时只按 UTC 自然日做 30 天 bounded 回查,防止一次后台校验演变成无界 provider 扫描。 now := s.now().UTC() startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) candidates := make([]int64, 0, mifaPayReceiptOrderDateLookbackDays+1) for dayOffset := 0; dayOffset <= mifaPayReceiptOrderDateLookbackDays; dayOffset++ { candidates = append(candidates, startOfToday.AddDate(0, 0, -dayOffset).UnixMilli()) } return candidates } func (s *Service) verifyV5PayRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) { if s.v5Pay == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } snapshot := s.receiptSnapshot(command) candidates, err := s.v5PayReceiptQueryCandidates(ctx, command) if err != nil { return ledger.CoinSellerRechargeReceiptVerification{}, err } if len(candidates) == 0 { snapshot.FailureReason = "v5pay country/currency candidates are not configured" return snapshot, nil } for _, candidate := range candidates { query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{ OrderID: command.ExternalOrderNo, CountryCode: candidate.CountryCode, CurrencyCode: candidate.CurrencyCode, }) if err != nil { if providerOrderNotFound(err) { continue } snapshot.FailureReason = err.Error() return snapshot, nil } return fillV5PayReceiptSnapshot(snapshot, command, query), nil } snapshot.FailureReason = "v5pay order not found for enabled country/currency candidates" return snapshot, nil } func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerification, command ledger.VerifyCoinSellerRechargeReceiptCommand, query V5PayQueryOrderResponse) ledger.CoinSellerRechargeReceiptVerification { snapshot.ProviderOrderID = strings.TrimSpace(query.ProviderOrderID) snapshot.Status = strings.TrimSpace(query.Status) snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency)) snapshot.RawJSON = strings.TrimSpace(query.RawJSON) if amount, err := parseProviderAmountMinor(query.Amount); err == nil { snapshot.ProviderAmountMinor = amount } if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) { snapshot.FailureReason = "v5pay order id mismatch" return snapshot } if !isV5PayReceiptPaid(query.Status) { snapshot.FailureReason = "v5pay order status " + firstNonEmpty(strings.TrimSpace(query.Status), "unknown") return snapshot } if command.ProviderCurrencyCode != "" && snapshot.CurrencyCode != "" && !strings.EqualFold(snapshot.CurrencyCode, command.ProviderCurrencyCode) { snapshot.FailureReason = "v5pay currency mismatch" return snapshot } if command.ProviderPayType != "" && query.ProductType != "" && !strings.EqualFold(query.ProductType, command.ProviderPayType) { snapshot.FailureReason = "v5pay product_type mismatch" return snapshot } snapshot.Verified = true return snapshot } type v5PayReceiptQueryCandidate struct { CountryCode string CurrencyCode string } func (s *Service) v5PayReceiptQueryCandidates(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) ([]v5PayReceiptQueryCandidate, error) { candidates := make([]v5PayReceiptQueryCandidate, 0, 4) seen := make(map[string]struct{}) addCandidate := func(countryCode string, currencyCode string) { if len(candidates) >= v5PayReceiptCandidateLimit { return } countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) currencyCode = strings.ToUpper(strings.TrimSpace(currencyCode)) if countryCode == "" || currencyCode == "" { return } key := countryCode + "|" + currencyCode if _, ok := seen[key]; ok { return } seen[key] = struct{}{} candidates = append(candidates, v5PayReceiptQueryCandidate{CountryCode: countryCode, CurrencyCode: currencyCode}) } if command.ProviderCountryCode != "" && command.ProviderCurrencyCode != "" { addCandidate(command.ProviderCountryCode, command.ProviderCurrencyCode) return candidates, nil } // V5Pay 查单需要国家/币种;运营只填订单号时,先从本地外部充值订单补条件,命中率最高且不会扩大查询面。 if order, ok := s.externalRechargeOrder(ctx, command.AppCode, command.ExternalOrderNo, ledger.PaymentProviderV5Pay); ok { addCandidate(order.CountryCode, order.CurrencyCode) } if s.repository == nil { return candidates, nil } channels, err := s.repository.ListThirdPartyPaymentChannels(ctx, ledger.ListThirdPartyPaymentChannelsQuery{ AppCode: command.AppCode, ProviderCode: ledger.PaymentProviderV5Pay, Status: ledger.ThirdPartyPaymentStatusActive, IncludeDisabledMethods: false, }) if err != nil { return nil, err } // 本地快照缺失时,只枚举当前 APP 启用的 V5Pay 国家/币种组合,并受候选上限保护,避免误配导致查单风暴。 for _, preferProvidedFields := range []bool{true, false} { for _, channel := range channels { if channel.ProviderCode != ledger.PaymentProviderV5Pay || channel.Status != ledger.ThirdPartyPaymentStatusActive { continue } for _, method := range channel.Methods { if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive { continue } matchesProvidedCountry := command.ProviderCountryCode == "" || strings.EqualFold(method.CountryCode, command.ProviderCountryCode) matchesProvidedCurrency := command.ProviderCurrencyCode == "" || strings.EqualFold(method.CurrencyCode, command.ProviderCurrencyCode) if preferProvidedFields != (matchesProvidedCountry && matchesProvidedCurrency) { continue } addCandidate(method.CountryCode, method.CurrencyCode) } } } return candidates, nil } func (s *Service) externalRechargeOrderCreatedAtMS(ctx context.Context, appCode string, orderID string, providerCode string) int64 { if order, ok := s.externalRechargeOrder(ctx, appCode, orderID, providerCode); ok { return order.CreatedAtMS } return 0 } func (s *Service) externalRechargeOrder(ctx context.Context, appCode string, orderID string, providerCode string) (ledger.ExternalRechargeOrder, bool) { if s == nil || s.repository == nil { return ledger.ExternalRechargeOrder{}, false } order, err := s.repository.GetExternalRechargeOrder(ctx, appCode, orderID) if err != nil || order.ProviderCode != providerCode { return ledger.ExternalRechargeOrder{}, false } return order, true } type receiptUSDTClient interface { VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) } func (s *Service) verifyUSDTRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, chain string, enabled bool, receiveAddress string, client receiptUSDTClient) (ledger.CoinSellerRechargeReceiptVerification, error) { if !enabled || strings.TrimSpace(receiveAddress) == "" { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured") } snapshot := s.receiptSnapshot(command) snapshot.Chain = chain snapshot.CurrencyCode = ledger.PaidCurrencyUSDT snapshot.ProviderAmountMinor = command.USDMinorAmount snapshot.ProviderOrderID = command.ExternalOrderNo snapshot.ReceiveAddress = strings.TrimSpace(receiveAddress) if chain == "trc20" && !isLikelyChainTransactionHash(command.ExternalOrderNo) { return s.verifyBinanceTRXDepositReceipt(ctx, command, snapshot) } if client == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" client is not configured") } rawJSON, err := client.VerifyUSDTTransfer(ctx, command.ExternalOrderNo, snapshot.ReceiveAddress, command.USDMinorAmount) snapshot.RawJSON = strings.TrimSpace(rawJSON) if err != nil { snapshot.Status = "unverified" snapshot.FailureReason = err.Error() return snapshot, nil } snapshot.Status = "confirmed" snapshot.Verified = true return snapshot, nil } func (s *Service) verifyBinanceTRXDepositReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, snapshot ledger.CoinSellerRechargeReceiptVerification) (ledger.CoinSellerRechargeReceiptVerification, error) { if s.binance == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "binance client is not configured") } // Binance off-chain 截图在付款方展示为 withdrawal,但收款账号侧只能通过 deposit history 查到账记录。 // 这里保持 provider 仍为 usdt_trc20,只把非链上 hash 的订单号收敛到 Binance TRX 入金校验。 record, err := s.binance.FindUSDTDeposit(ctx, ports.BinanceDepositLookupRequest{ AppCode: command.AppCode, ExternalID: command.ExternalOrderNo, Coin: ledger.PaidCurrencyUSDT, Network: "TRX", ToAddress: snapshot.ReceiveAddress, AmountMinor: command.USDMinorAmount, }) if err != nil { snapshot.Status = "unverified" snapshot.FailureReason = err.Error() return snapshot, nil } snapshot.Verified = true snapshot.Status = "confirmed" snapshot.ProviderOrderID = record.TxID snapshot.ProviderAmountMinor = record.AmountMinor snapshot.ReceiveAddress = record.Address snapshot.RawJSON = strings.TrimSpace(record.RawJSON) return snapshot, nil } func (s *Service) receiptSnapshot(command ledger.VerifyCoinSellerRechargeReceiptCommand) ledger.CoinSellerRechargeReceiptVerification { return ledger.CoinSellerRechargeReceiptVerification{ ProviderCode: command.ProviderCode, ExternalOrderNo: command.ExternalOrderNo, Chain: command.Chain, VerifiedAtMS: s.now().UTC().UnixMilli(), } } func isLikelyChainTransactionHash(value string) bool { value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") if len(value) != 64 { return false } for _, r := range value { if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return false } } return true } func isMifaPayReceiptPaid(status string) bool { switch strings.ToUpper(strings.TrimSpace(status)) { case "1", "SUCCESS": return true default: return false } } func isV5PayReceiptPaid(status string) bool { return strings.TrimSpace(status) == "2" } func normalizeReceiptChain(chain string) string { chain = strings.ToLower(strings.TrimSpace(chain)) chain = strings.ReplaceAll(chain, "-", "_") switch chain { case "trc20", "trx", "tron", "usdt_trc20": return "trc20" case "bep20", "bsc", "bnb", "usdt_bep20": return "bep20" default: return chain } } func providerOrderNotFound(err error) bool { if err == nil { return false } if xerr.IsCode(err, xerr.NotFound) { return true } message := strings.ToLower(err.Error()) return strings.Contains(message, "not found") || strings.Contains(message, "not exist") || strings.Contains(message, "does not exist") || strings.Contains(message, "no data") || strings.Contains(message, "order不存在") || strings.Contains(message, "订单不存在") }