2026-06-15 19:17:09 +08:00

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)
}