using System.Collections.Specialized; using System.Net; using System.Security.Cryptography; using System.Text; using Bitget_Server.Application.State; using Bitget_Server.Domain; using Bitget_Server.Domain.Enums; using Bitget_Server.Domain.Models; using Bitget_Server.Infrastructure.Utils; using Newtonsoft.Json; namespace Bitget_Server.Infrastructure.Bitget; /// /// 기존 WinForms WebConnection.SendWebServer / GenerateSignString 로직을 이식. /// HttpClient 기반으로 재작성하고, 모든 외부 호출 전에 BitgetRateLimiter를 거친다. /// public class BitgetClient { private readonly HttpClient _http; private readonly BitgetRateLimiter _limiter; private readonly MarketState _state; private readonly ILogger _logger; public BitgetClient( HttpClient http, BitgetRateLimiter limiter, MarketState state, ILogger logger) { _http = http; _limiter = limiter; _state = state; _logger = logger; } /// /// 기존 WebConnection.SendWebServer와 동일한 시그니처를 유지한다. /// 응답을 ExchangeWebReqType에 따라 적절히 역직렬화하여 반환한다. /// public async Task SendWebServerAsync( ExchangeWebReqType exchangeWebReqType, string url = "", MarketCandleDataList marketCandleDataList = null, MCandleTimeType interval = MCandleTimeType.M1, string postData = "", string subUri = "", NameValueCollection headers = null, CancellationToken ct = default) { var requestUrl = string.IsNullOrEmpty(url) ? GetDefaultUrl(exchangeWebReqType) : url; if (string.IsNullOrEmpty(requestUrl)) { _logger.LogWarning("URL 미정 ExchangeWebReqType: {Type}", exchangeWebReqType); return null; } // 1차 호출 → 429 시 1회 재시도 for (int attempt = 0; attempt < 2; attempt++) { await _limiter.AcquireAsync(ct).ConfigureAwait(false); try { HttpRequestMessage req; if (!string.IsNullOrEmpty(postData)) { req = new HttpRequestMessage(HttpMethod.Post, requestUrl) { Content = new StringContent(postData, Encoding.UTF8, headers != null ? "application/json" : "application/x-www-form-urlencoded") }; } else { req = new HttpRequestMessage(HttpMethod.Get, requestUrl); } if (headers != null) { foreach (string key in headers) req.Headers.TryAddWithoutValidation(key, headers[key]); if (req.Content != null) req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); } using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); var resResult = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // 429 (TooManyRequests) 는 1회에 한해 500ms 후 재시도 if ((int)resp.StatusCode == 429 && attempt == 0) { _logger.LogWarning("Bitget 429 TooManyRequests — 500ms 후 1회 재시도 ({Type})", exchangeWebReqType); await Task.Delay(500, ct).ConfigureAwait(false); continue; } if (!resp.IsSuccessStatusCode) { _logger.LogError("Bitget API 에러 {Status}: {Body}", (int)resp.StatusCode, resResult); return null; } return Parse(exchangeWebReqType, resResult, marketCandleDataList, interval); } catch (HttpRequestException webEx) { _logger.LogError(webEx, "Bitget HTTP 호출 실패: {Type}", exchangeWebReqType); return null; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Bitget 처리 중 예외"); return null; } } return null; } private string GetDefaultUrl(ExchangeWebReqType type) => type switch { ExchangeWebReqType.UpbitMarket => UpbitEndpoints.MarketInfo, ExchangeWebReqType.BitgetMarket_Spot => BitgetEndpoints.MarketInfo_Coin, ExchangeWebReqType.BitgetMarket_Future => BitgetEndpoints.MarketInfo_Future, ExchangeWebReqType.BitgetCandleStick_Coin => BitgetEndpoints.MarketCandle_Coin, ExchangeWebReqType.BitgetCandleStick_Future => BitgetEndpoints.MarketCandle_Future, _ => string.Empty }; private object Parse(ExchangeWebReqType type, string resResult, MarketCandleDataList marketCandleDataList, MCandleTimeType interval) { switch (type) { case ExchangeWebReqType.BitgetMarket_Spot: { var datalist = JsonConvert.DeserializeObject>(resResult); var marketInfo = JsonConvert.DeserializeObject>>(datalist["data"]?.ToString()); return marketInfo.FindAll(x => x["coin"].ToString().Contains("USDT")); } case ExchangeWebReqType.BitgetMarket_Future: { var datalist = JsonConvert.DeserializeObject>(resResult); var marketInfos = JsonConvert.DeserializeObject>>(datalist["data"]?.ToString()); var newList = new List(); foreach (var item in marketInfos) { var marketInfo = new MarketInfo { MarketName = item["symbol"].ToString(), ExchangePlace = Enum.GetName(typeof(ExchangePlace), _state.selectedChangePlace), lastPr = Convert.ToDouble(item["lastPr"]), change24h = Convert.ToDouble(item["change24h"]), baseVolume = Convert.ToDouble(item["baseVolume"]), quoteVolume = Convert.ToDouble(item["quoteVolume"]), high24h = Convert.ToDouble(item["high24h"]), low24h = Convert.ToDouble(item["low24h"]), open24h = Convert.ToDouble(item["open24h"]) }; newList.Add(marketInfo); } lock (_state.SyncRoot) { _state.marketInfoList = newList; } return newList; } case ExchangeWebReqType.BitgetCandleStick_Future: { if (marketCandleDataList == null) return null; var datalist = JsonConvert.DeserializeObject>(resResult); var datas = JsonConvert.DeserializeObject>>(datalist["data"]?.ToString()); var candleDataList = marketCandleDataList.candleDataList[interval]; lock (_state.SyncRoot) { foreach (var item in datas) { var candleData = new CandleData { Argument = Convertor.GetUnixtimeStampToDateTime(long.Parse(item[0].ToString())), Open = Convert.ToDouble(item[1]), High = Convert.ToDouble(item[2]), Low = Convert.ToDouble(item[3]), Close = Convert.ToDouble(item[4]), Vol_C = Convert.ToDouble(item[5]), Vol_USD = Convert.ToDouble(item[6]) }; if (item == datas.Last()) { var span = GetIntervalSpan(interval); var same = candleDataList.Find(i => candleData.Argument <= i.Argument && i.Argument < (candleData.Argument + span) && i.WebSocketUpdate); if (same != null) { same.Open = candleData.Open; same.Low = Math.Min(same.Low, candleData.Low); same.High = Math.Max(same.High, candleData.High); same.WebServiceUpdate = true; } else { candleDataList.Add(candleData); candleData.WebServiceUpdate = true; } } else { candleDataList.Add(candleData); candleData.WebServiceUpdate = true; } } } return null; } case ExchangeWebReqType.BitgetAccountList_Future: { var datalist = JsonConvert.DeserializeObject>(resResult); return JsonConvert.DeserializeObject>>(datalist["data"]?.ToString()); } case ExchangeWebReqType.BitgetOrder_Future: case ExchangeWebReqType.BitgetCancelOrder_Future: case ExchangeWebReqType.BitgetChangeLeverage_Future: case ExchangeWebReqType.BitgetChangeMarginMode_Future: case ExchangeWebReqType.BitgetChangePositionMode_Future: case ExchangeWebReqType.BitgetOrderDetail_Future: case ExchangeWebReqType.BitgetGetTimeStamp: case ExchangeWebReqType.BitgetPendingOrder_Future: return JsonConvert.DeserializeObject>(resResult); default: return null; } } private static TimeSpan GetIntervalSpan(MCandleTimeType interval) => interval switch { MCandleTimeType.M1 => TimeSpan.FromMinutes(1), MCandleTimeType.M3 => TimeSpan.FromMinutes(3), MCandleTimeType.M5 => TimeSpan.FromMinutes(5), MCandleTimeType.M15 => TimeSpan.FromMinutes(15), MCandleTimeType.M30 => TimeSpan.FromMinutes(30), MCandleTimeType.H1 => TimeSpan.FromHours(1), MCandleTimeType.H2 => TimeSpan.FromHours(2), MCandleTimeType.H3 => TimeSpan.FromHours(3), MCandleTimeType.H4 => TimeSpan.FromHours(4), MCandleTimeType.H6 => TimeSpan.FromHours(6), MCandleTimeType.H12 => TimeSpan.FromHours(12), _ => TimeSpan.Zero }; /// /// 기존 IndicatorProcess.GenerateSignString을 그대로 이식. /// public static string GenerateSignString(string timestamp, string method, string requestPath, string queryString, string body, string secretKey) { method = method.ToUpper(); body = body == null ? "" : body; queryString = string.IsNullOrWhiteSpace(queryString) ? "" : "?" + queryString; string preHash = timestamp + method + requestPath + queryString + body; var encSecretKey_byteData = Encoding.UTF8.GetBytes(secretKey); var encPreHash_byteData = Encoding.UTF8.GetBytes(preHash); return Convert.ToBase64String(new HMACSHA256(encSecretKey_byteData).ComputeHash(encPreHash_byteData)); } }