2026-07-09 18:15:57 +08:00

454 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
amount, amountErr := parseMifaPayReceiptAmountMinor(query.Amount, snapshot.CurrencyCode)
if amountErr == 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 amountErr != nil || snapshot.ProviderAmountMinor <= 0 {
// MiFaPay 付款成功但金额字段缺失/坏值时,后台无法建立资金事实,必须失败而不是退化成只验订单号。
snapshot.FailureReason = "mifapay amount is invalid"
return snapshot
}
if command.ProviderAmountMinor > 0 {
if snapshot.ProviderAmountMinor != command.ProviderAmountMinor {
snapshot.FailureReason = "mifapay amount 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)
amount, amountErr := parseProviderAmountMinor(query.Amount)
if amountErr == 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 amountErr != nil || snapshot.ProviderAmountMinor <= 0 {
// V5Pay 人工校验依赖回包金额建立付款事实;即使内部调用未传期望金额,坏金额也不能放行。
snapshot.FailureReason = "v5pay amount is invalid"
return snapshot
}
if command.ProviderAmountMinor > 0 {
if snapshot.ProviderAmountMinor != command.ProviderAmountMinor {
snapshot.FailureReason = "v5pay amount 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
}
func parseMifaPayReceiptAmountMinor(value string, currencyCode string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
if mifaPayUsesWholeMajorAmount(currencyCode) {
// MiFaPay INR 查单回包沿用上游 contractamount 是整数卢比主单位;本地快照仍统一存 minor。
return parsed * 100, nil
}
return parsed, nil
}
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, "订单不存在")
}