414 lines
17 KiB
Go
414 lines
17 KiB
Go
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, "订单不存在")
|
||
}
|