494 lines
21 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, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)), s.tronUSDT)
case ledger.PaymentProviderUSDTBEP20:
return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode)), s.bscUSDT)
case ledger.PaymentProviderUSDTC2C:
// C2C 是 Binance 账户内收款事实,不属于 TRON/BSC空地址和关闭的链上开关不影响只读查单。
return s.verifyUSDTRechargeReceipt(ctx, command, "c2c", false, "", nil)
case ledger.PaymentProviderUSDTOffchain:
// Binance Off-chain 是 Deposit History 中的 TRX 入账事实;后台 SQL 地址优先于 YAML
// 但仍要求 TRC20 功能开启,避免已停用的收款链被人工入口绕过。
return s.verifyUSDTRechargeReceipt(ctx, command, "offchain", s.externalRecharge.USDTTRC20Enabled, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)), nil)
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)
command.ReceiveAddress = strings.TrimSpace(command.ReceiveAddress)
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, ledger.PaymentProviderUSDTOffchain:
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.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) {
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 == "c2c" {
// 查单来源由运营显式选择C2C 只读取 Binance 账户事实,绝不从 TRON/BSC 隐式降级或猜测。
return s.verifyBinanceTransferReceipt(ctx, command, snapshot, binanceNetworkForReceiptChain(chain))
}
if !enabled || snapshot.ReceiveAddress == "" {
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured")
}
if chain == "offchain" {
// Off-chain 编号不是公链 hash只能走 Binance Deposit History地址和金额仍必须与当前 App 配置精确一致。
return s.verifyBinanceTransferReceipt(ctx, command, snapshot, binanceNetworkForReceiptChain(chain))
}
if !isLikelyChainTransactionHash(command.ExternalOrderNo) {
// TRON/BSC 只接受标准交易哈希;非链上编号必须由运营明确改选 C2C避免跨事实源误校验。
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "usdt "+chain+" receipt requires transaction hash")
}
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) verifyBinanceTransferReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, snapshot ledger.CoinSellerRechargeReceiptVerification, network string) (ledger.CoinSellerRechargeReceiptVerification, error) {
if s.binance == nil {
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "binance client is not configured")
}
// 非链上编号可能来自 Deposit History 的 off-chain transfer也可能来自 Funding Account 的 Pay C2C。
// Binance client 必须在收款账号侧验证正向入账;付款方截图本身不能成为放币依据。
record, err := s.binance.FindUSDTTransfer(ctx, ports.BinanceTransferLookupRequest{
AppCode: command.AppCode,
ExternalID: command.ExternalOrderNo,
Coin: ledger.PaidCurrencyUSDT,
Network: network,
ToAddress: snapshot.ReceiveAddress,
AmountMinor: command.USDMinorAmount,
OrderTimeMS: command.OrderDateMS,
})
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
if snapshot.Chain == "c2c" && strings.TrimSpace(record.Coin) != "" {
// C2C Pay 实际收款可能是 USDT 或 USDC凭证必须保存 Binance 返回币种,不能继续伪装成表单默认 USDT。
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(record.Coin))
}
// Pay C2C 没有链上收款地址;只有 Deposit History 命中时才用 Binance 返回地址覆盖配置快照。
if snapshot.Chain != "c2c" && strings.TrimSpace(record.Address) != "" {
snapshot.ReceiveAddress = record.Address
}
snapshot.RawJSON = strings.TrimSpace(record.RawJSON)
return snapshot, nil
}
func binanceNetworkForReceiptChain(chain string) string {
// Binance Deposit History 使用交易所网络代码而不是后台 provider 名称Pay C2C 不校验网络,
// 但同一个入口也支持 off-chain Deposit History因此这里必须保留准确映射。
switch strings.ToLower(strings.TrimSpace(chain)) {
case "bep20":
return "BSC"
case "c2c":
return "C2C"
case "offchain":
return "OFFCHAIN"
default:
return "TRX"
}
}
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, "订单不存在")
}