2026-06-22 16:55:22 +08:00

953 lines
38 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"
"encoding/json"
"errors"
"math"
"net/url"
"strconv"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool
USDTTRC20Address string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
}
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
type MifaPayCreateOrderRequest struct {
OrderID string
TargetUserID int64
ProductName string
CoinAmount int64
AmountMinor int64
CurrencyCode string
CountryCode string
PayWay string
PayType string
NotifyURL string
ReturnURL string
ClientIP string
Language string
ProviderAmountMinor int64
PayerName string
PayerAccount string
PayerEmail string
}
type MifaPayCreateOrderResponse struct {
OrderID string
ProviderOrderID string
PayURL string
RawJSON string
}
type MifaPayQueryOrderRequest struct {
OrderID string
ProviderOrderID string
OrderCreatedAtMS int64
}
type MifaPayQueryOrderResponse struct {
OrderID string
ProviderOrderID string
Status string
Amount string
Currency string
PayType string
RawJSON string
}
type MifaPayNotifyData struct {
OrderID string
ProviderOrderID string
OrderStatus string
Amount string
Currency string
PayType string
RawJSON string
}
// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。
type V5PayCreateOrderRequest struct {
OrderID string
TargetUserID int64
ProductName string
CoinAmount int64
AmountMinor int64
CurrencyCode string
CountryCode string
ProductType string
NotifyURL string
ReturnURL string
Language string
ProviderAmountMinor int64
PayerName string
PayerAccount string
PayerEmail string
}
type V5PayCreateOrderResponse struct {
OrderID string
ProviderOrderID string
PayURL string
RawJSON string
}
type V5PayQueryOrderRequest struct {
OrderID string
ProviderOrderID string
CountryCode string
CurrencyCode string
}
type V5PayQueryOrderResponse struct {
OrderID string
ProviderOrderID string
Status string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayNotifyData struct {
OrderID string
ProviderOrderID string
OrderStatus string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayProductTypesRequest struct {
CountryCode string
CurrencyCode string
}
type V5PayProductType struct {
ProductType string
ProductName string
Currency string
SupportCashierMode int
}
type V5PayProductTypesResponse struct {
PayinList []V5PayProductType
RawJSON string
}
// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节service 只处理订单状态。
type MifaPayClient interface {
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
QueryOrder(ctx context.Context, req MifaPayQueryOrderRequest) (MifaPayQueryOrderResponse, error)
ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error)
}
// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节service 只处理订单状态。
type V5PayClient interface {
CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error)
QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error)
ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error)
ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error)
}
// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。
type TronUSDTClient interface {
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
}
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
return config
}
// ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。
func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
if strings.TrimSpace(query.Status) != "" {
query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status)
if query.Status == "" {
return nil, xerr.New(xerr.InvalidArgument, "status is invalid")
}
}
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListThirdPartyPaymentChannels(ctx, query)
}
func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) {
if command.MethodID <= 0 || command.OperatorUserID <= 0 {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command)
}
func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate)
if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.UpdateThirdPartyPaymentRate(ctx, command)
}
// ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。
func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) {
query.AppCode = appcode.Normalize(query.AppCode)
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode))
if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" {
return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete")
}
if s.repository == nil {
return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, query.AppCode)
options, err := s.repository.ListH5RechargeOptions(ctx, query)
if err != nil {
return ledger.H5RechargeOptions{}, err
}
if s.mifaPay == nil && s.v5Pay == nil {
options.PaymentMethods = nil
} else {
filtered := options.PaymentMethods[:0]
for _, method := range options.PaymentMethods {
// H5 只展示当前运行时已装配 client 的支付公司;后台仍可维护全部 provider 的开关和汇率。
if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil {
filtered = append(filtered, method)
}
if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil {
filtered = append(filtered, method)
}
}
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
}
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
return options, nil
}
func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod {
if s.v5Pay == nil || len(methods) == 0 {
return methods
}
type productKey struct {
country string
currency string
}
allowedByKey := map[productKey]map[string]bool{}
filtered := methods[:0]
for _, method := range methods {
if method.ProviderCode != ledger.PaymentProviderV5Pay {
filtered = append(filtered, method)
continue
}
key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))}
allowed, ok := allowedByKey[key]
if !ok {
allowed = map[string]bool{}
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency})
if err == nil {
for _, product := range products.PayinList {
// V5Pay 的 appKey 可按国家/币种/产品单独开通H5 只展示当前应用实际可走收银台的产品,避免用户点到 1013 app invalid。
if product.SupportCashierMode == 0 {
continue
}
if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) {
continue
}
if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" {
allowed[productType] = true
}
}
}
allowedByKey[key] = allowed
}
if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] {
filtered = append(filtered, method)
}
}
return filtered
}
// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
command = normalizeCreateExternalRechargeOrderCommand(command)
if err := validateCreateExternalRechargeOrderCommand(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)
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateExternalRechargeProduct(product, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
switch command.ProviderCode {
case ledger.PaymentProviderUSDTTRC20:
return s.createUSDTRechargeOrder(ctx, command, product)
case ledger.PaymentProviderMifaPay:
return s.createMifaPayRechargeOrder(ctx, command, product)
case ledger.PaymentProviderV5Pay:
return s.createV5PayRechargeOrder(ctx, command, product)
default:
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
}
}
func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
}
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address)
}
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (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 := validateMifaPayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
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: order.TargetUserID,
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) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (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 := validateV5PayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &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: order.TargetUserID,
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 normalizeMifaPayOrderError(err error) error {
return normalizeExternalProviderOrderError(err, "mifapay")
}
type mifaPayProviderPayloadError interface {
ProviderPayloadJSON() string
}
func mifaPayProviderPayloadJSON(err error) string {
return providerPayloadJSON(err)
}
func normalizeExternalProviderOrderError(err error, provider string) error {
if err == nil {
return nil
}
if _, ok := xerr.As(err); ok {
return err
}
// HTTP、JSON、签名和响应结构错误都是外部支付依赖不可用不能把普通 Go error 透到 gRPC 边界,
// 否则 gateway 只能降级成 INTERNAL_ERROR前端拿不到“上游支付失败”的可重试语义。
return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid")
}
func providerPayloadJSON(err error) string {
var payloadErr mifaPayProviderPayloadError
if errors.As(err, &payloadErr) {
// 支付网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
// 普通网络错误、JSON 解析错误或本地签名错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
}
return ""
}
// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。
func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.OrderID = strings.TrimSpace(command.OrderID)
command.TxHash = strings.TrimSpace(command.TxHash)
if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if order.TargetUserID != command.TargetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order")
}
if s.tronUSDT == nil {
return s.repository.AttachExternalRechargeTx(ctx, command, "")
}
rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount)
if err != nil {
_, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON)
return ledger.ExternalRechargeOrder{}, err
}
if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON)
}
func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (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 targetUserID > 0 && order.TargetUserID != targetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
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) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if limit <= 0 {
return 0, nil
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
if err != nil {
return 0, err
}
processed := 0
for _, order := range orders {
// 补偿任务只负责把“已经跳转到三方收银台但回调/回跳未完成”的订单拉回服务端状态机。
// 单笔三方查询失败不能中断整批;失败订单会保留 redirected下一轮继续查成功/终态失败则复用幂等落账。
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
processed++
}
return processed, nil
}
func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
OrderCreatedAtMS: order.CreatedAtMS,
})
if err != nil {
// H5 轮询不能因为 MiFaPay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := MifaPayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
Amount: query.Amount,
Currency: query.Currency,
PayType: query.PayType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "1":
if !mifaPayQueryMatchesOrder(data, order) {
// 主动查询和回调使用同一笔本地订单快照校验金额、币种和支付类型;不匹配时拒绝入账,避免三方串单或通道异常。
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "4":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status 4", query.RawJSON)
case "0", "5", "":
return order, nil
default:
return order, nil
}
}
func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
CountryCode: order.CountryCode,
CurrencyCode: order.CurrencyCode,
})
if err != nil {
// H5 轮询不能因为 V5Pay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := V5PayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
OrderStatus: query.Status,
Amount: query.Amount,
Currency: query.Currency,
ProductType: query.ProductType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
// 主动查询和回调使用同一笔本地订单快照校验金额、币种和产品编码;不匹配时拒绝入账,避免三方串单或通道异常。
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "3", "5", "6":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON)
case "0", "1", "":
return order, nil
default:
return order, nil
}
}
// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.mifaPay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.mifaPay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
if !strings.EqualFold(data.OrderStatus, "SUCCESS") {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
}
if !mifaPayNotifyMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
}
// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。
func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.v5Pay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
switch strings.TrimSpace(data.OrderStatus) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
case "3", "5", "6":
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
default:
return order, true, nil
}
}
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode))
command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType)
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 normalizeMifaPayLanguage(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "en"
}
if comma := strings.IndexByte(value, ','); comma >= 0 {
value = value[:comma]
}
if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 {
value = value[:semicolon]
}
value = strings.TrimSpace(value)
if !validMifaPayLanguageTag(value) {
return "en"
}
// wallet-service 是 MiFaPay 的最后一道协议边界;即使未来有非 gateway 调用方传入浏览器语言列表,
// 这里也只向三方支付发送单个 BCP47 风格标签,避免订单落库后被 MiFaPay 参数格式校验拒掉。
return value
}
func validMifaPayLanguageTag(value string) bool {
if value == "" {
return false
}
parts := strings.Split(value, "-")
if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) {
return false
}
for _, part := range parts[1:] {
if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) {
return false
}
}
return true
}
func allASCIILetters(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') {
return false
}
}
return value != ""
}
func allASCIILettersOrDigits(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return false
}
}
return value != ""
}
func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error {
if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete")
}
if command.AudienceType == "" || command.ProviderCode == "" {
return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid")
}
if len(command.CommandID) > 128 {
return xerr.New(xerr.InvalidArgument, "command_id is too long")
}
return nil
}
func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error {
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
return xerr.New(xerr.Conflict, "recharge product is not active")
}
if product.Platform != ledger.RechargeProductPlatformWeb {
return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product")
}
if product.AudienceType != command.AudienceType {
return xerr.New(xerr.Conflict, "recharge product audience does not match")
}
if !rechargeProductSupportsRegion(product, command.TargetRegionID) {
return xerr.New(xerr.Conflict, "recharge product is not available in user region")
}
return nil
}
func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "mifapay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "mifapay method country does not match")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "v5pay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "v5pay method country does not match")
}
if strings.TrimSpace(method.PayType) == "" {
return xerr.New(xerr.InvalidArgument, "v5pay product type is required")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) {
if usdMinor <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
}
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
if err != nil || value <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid")
}
return int64(math.Round(float64(usdMinor) * value)), nil
}
func amountMicroToUSDMinor(amountMicro int64) int64 {
if amountMicro <= 0 {
return 0
}
return amountMicro / 10_000
}
func validNonNegativeDecimal(value string) bool {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && parsed >= 0
}
func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) {
return false
}
amount, err := parseProviderAmountMinor(data.Amount)
return err == nil && amount == order.ProviderAmountMinor
}
func parseProviderAmountMinor(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
if !strings.Contains(value, ".") {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
return parsed * 100, nil
}
parts := strings.SplitN(value, ".", 2)
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
if err != nil {
return 0, err
}
fraction := parts[1]
if len(fraction) > 2 {
fraction = fraction[:2]
}
for len(fraction) < 2 {
fraction += "0"
}
minor, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
return whole*100 + minor, nil
}
func mifaPayProviderReturnURL(returnURL string) string {
returnURL = strings.TrimSpace(returnURL)
if returnURL == "" {
return returnURL
}
parsed, err := url.Parse(returnURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return returnURL
}
// MiFaPay 生产网关会对 payment.resultRedirect 做严格格式校验,动态 query 会在下单阶段被拒。
// 订单 ID 已经通过创建订单响应返回给 H5 并落在钱包库,回跳页只能作为稳定入口,不能依赖三方回传本地查询参数。
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func v5PayProviderReturnURL(returnURL string) string {
return mifaPayProviderReturnURL(returnURL)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func mustJSONText(value any) string {
body, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(body)
}