953 lines
38 KiB
Go
953 lines
38 KiB
Go
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)
|
||
}
|