275 lines
8.1 KiB
Go
275 lines
8.1 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
)
|
|
|
|
type exchangeRateClient interface {
|
|
LatestUSDRates(ctx context.Context, currencies []string) (exchangeRateResult, error)
|
|
}
|
|
|
|
type exchangeRateResult struct {
|
|
Rates map[string]string `json:"rates"`
|
|
SourceNames []string `json:"sourceNames"`
|
|
Sources []exchangeRateSourceStatusDTO `json:"sources"`
|
|
}
|
|
|
|
type exchangeRateSourceStatusDTO struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Success bool `json:"success"`
|
|
Currencies []string `json:"currencies,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type thirdPartyPaymentRateSyncDTO struct {
|
|
UpdatedCount int `json:"updatedCount"`
|
|
SkippedCount int `json:"skippedCount"`
|
|
MissingCurrencies []string `json:"missingCurrencies"`
|
|
SourceNames []string `json:"sourceNames"`
|
|
Sources []exchangeRateSourceStatusDTO `json:"sources"`
|
|
Rates map[string]string `json:"rates"`
|
|
}
|
|
|
|
type httpExchangeRateClient struct {
|
|
client *http.Client
|
|
sources []exchangeRateSource
|
|
}
|
|
|
|
type exchangeRateSource struct {
|
|
name string
|
|
url string
|
|
parse func([]byte) (map[string]float64, error)
|
|
}
|
|
|
|
func newDefaultExchangeRateClient() exchangeRateClient {
|
|
return &httpExchangeRateClient{
|
|
client: &http.Client{Timeout: 8 * time.Second},
|
|
sources: []exchangeRateSource{
|
|
{name: "open.er-api.com", url: "https://open.er-api.com/v6/latest/USD", parse: parseOpenExchangeRateAPI},
|
|
{name: "fawazahmed0-currency-api", url: "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json", parse: parseFawazCurrencyAPI},
|
|
{name: "FloatRates", url: "https://www.floatrates.com/daily/usd.json", parse: parseFloatRates},
|
|
{name: "Frankfurter", url: "https://api.frankfurter.dev/v1/latest?base=USD", parse: parseFrankfurterRates},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *httpExchangeRateClient) LatestUSDRates(ctx context.Context, currencies []string) (exchangeRateResult, error) {
|
|
required, rates := splitRequiredCurrencies(currencies)
|
|
if len(required) == 0 {
|
|
return exchangeRateResult{Rates: rates}, nil
|
|
}
|
|
sourceNames := make([]string, 0, len(c.sources))
|
|
sourceStatuses := make([]exchangeRateSourceStatusDTO, 0, len(c.sources))
|
|
for _, source := range c.sources {
|
|
// 每个源独立失败,不阻断后续源;同一个币种只取最先成功源,避免不同源的更新时间混在同一个币种上来回抖动。
|
|
sourceRates, err := c.fetchSource(ctx, source)
|
|
if err != nil {
|
|
sourceStatuses = append(sourceStatuses, exchangeRateSourceStatusDTO{Name: source.name, URL: source.url, Success: false, Error: err.Error()})
|
|
continue
|
|
}
|
|
matched := make([]string, 0, len(required))
|
|
for currency := range required {
|
|
if _, exists := rates[currency]; exists {
|
|
continue
|
|
}
|
|
value := sourceRates[currency]
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
rates[currency] = formatExchangeRate(value)
|
|
matched = append(matched, currency)
|
|
}
|
|
sort.Strings(matched)
|
|
if len(matched) > 0 {
|
|
sourceNames = append(sourceNames, source.name)
|
|
}
|
|
sourceStatuses = append(sourceStatuses, exchangeRateSourceStatusDTO{Name: source.name, URL: source.url, Success: true, Currencies: matched})
|
|
if len(rates)-usdRateCount(rates) == len(required) {
|
|
break
|
|
}
|
|
}
|
|
if len(rates)-usdRateCount(rates) == 0 {
|
|
return exchangeRateResult{}, errors.New("no exchange rate source returned requested currencies")
|
|
}
|
|
return exchangeRateResult{Rates: rates, SourceNames: sourceNames, Sources: sourceStatuses}, nil
|
|
}
|
|
|
|
func (c *httpExchangeRateClient) fetchSource(ctx context.Context, source exchangeRateSource) (map[string]float64, error) {
|
|
client := c.client
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 8 * time.Second}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "HYAppAdmin/1.0 exchange-rate-sync")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return source.parse(body)
|
|
}
|
|
|
|
func parseOpenExchangeRateAPI(body []byte) (map[string]float64, error) {
|
|
var payload struct {
|
|
Result string `json:"result"`
|
|
Rates map[string]float64 `json:"rates"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if payload.Result != "" && payload.Result != "success" {
|
|
return nil, fmt.Errorf("result %s", payload.Result)
|
|
}
|
|
return normalizeFloatRateMap(payload.Rates), nil
|
|
}
|
|
|
|
func parseFrankfurterRates(body []byte) (map[string]float64, error) {
|
|
var payload struct {
|
|
Rates map[string]float64 `json:"rates"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeFloatRateMap(payload.Rates), nil
|
|
}
|
|
|
|
func parseFawazCurrencyAPI(body []byte) (map[string]float64, error) {
|
|
var payload struct {
|
|
Rates map[string]float64 `json:"usd"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeFloatRateMap(payload.Rates), nil
|
|
}
|
|
|
|
func parseFloatRates(body []byte) (map[string]float64, error) {
|
|
var payload map[string]struct {
|
|
Code string `json:"code"`
|
|
AlphaCode string `json:"alphaCode"`
|
|
Rate string `json:"rate"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
rates := make(map[string]float64, len(payload))
|
|
for key, item := range payload {
|
|
currency := strings.ToUpper(firstNonEmptyString(item.AlphaCode, item.Code, key))
|
|
value, err := strconv.ParseFloat(strings.TrimSpace(item.Rate), 64)
|
|
if currency == "" || err != nil || value <= 0 {
|
|
continue
|
|
}
|
|
rates[currency] = value
|
|
}
|
|
return rates, nil
|
|
}
|
|
|
|
func normalizeFloatRateMap(input map[string]float64) map[string]float64 {
|
|
rates := make(map[string]float64, len(input))
|
|
for currency, value := range input {
|
|
currency = strings.ToUpper(strings.TrimSpace(currency))
|
|
if currency == "" || value <= 0 {
|
|
continue
|
|
}
|
|
rates[currency] = value
|
|
}
|
|
return rates
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func collectThirdPartyPaymentMethods(channels []*walletv1.ThirdPartyPaymentChannel) []*walletv1.ThirdPartyPaymentMethod {
|
|
seen := make(map[int64]struct{})
|
|
methods := make([]*walletv1.ThirdPartyPaymentMethod, 0)
|
|
for _, channel := range channels {
|
|
for _, method := range channel.GetMethods() {
|
|
if method.GetMethodId() <= 0 {
|
|
continue
|
|
}
|
|
if _, exists := seen[method.GetMethodId()]; exists {
|
|
continue
|
|
}
|
|
seen[method.GetMethodId()] = struct{}{}
|
|
methods = append(methods, method)
|
|
}
|
|
}
|
|
return methods
|
|
}
|
|
|
|
func collectThirdPartyPaymentCurrencies(methods []*walletv1.ThirdPartyPaymentMethod) []string {
|
|
currencies := make(map[string]struct{})
|
|
for _, method := range methods {
|
|
if currency := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode())); currency != "" {
|
|
currencies[currency] = struct{}{}
|
|
}
|
|
}
|
|
return sortedMapKeys(currencies)
|
|
}
|
|
|
|
func splitRequiredCurrencies(currencies []string) (map[string]struct{}, map[string]string) {
|
|
required := make(map[string]struct{})
|
|
rates := make(map[string]string)
|
|
for _, currency := range currencies {
|
|
currency = strings.ToUpper(strings.TrimSpace(currency))
|
|
if currency == "" {
|
|
continue
|
|
}
|
|
if currency == "USD" {
|
|
rates[currency] = "1.00000000"
|
|
continue
|
|
}
|
|
required[currency] = struct{}{}
|
|
}
|
|
return required, rates
|
|
}
|
|
|
|
func usdRateCount(rates map[string]string) int {
|
|
if rates["USD"] == "" {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func sortedMapKeys(values map[string]struct{}) []string {
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func formatExchangeRate(value float64) string {
|
|
return strconv.FormatFloat(value, 'f', 8, 64)
|
|
}
|