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