단순 이관
This commit is contained in:
277
Bitget_Server/Infrastructure/Bitget/BitgetClient.cs
Normal file
277
Bitget_Server/Infrastructure/Bitget/BitgetClient.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 기존 WinForms WebConnection.SendWebServer / GenerateSignString 로직을 이식.
|
||||
/// HttpClient 기반으로 재작성하고, 모든 외부 호출 전에 BitgetRateLimiter를 거친다.
|
||||
/// </summary>
|
||||
public class BitgetClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly BitgetRateLimiter _limiter;
|
||||
private readonly MarketState _state;
|
||||
private readonly ILogger<BitgetClient> _logger;
|
||||
|
||||
public BitgetClient(
|
||||
HttpClient http,
|
||||
BitgetRateLimiter limiter,
|
||||
MarketState state,
|
||||
ILogger<BitgetClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_limiter = limiter;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 WebConnection.SendWebServer와 동일한 시그니처를 유지한다.
|
||||
/// 응답을 ExchangeWebReqType에 따라 적절히 역직렬화하여 반환한다.
|
||||
/// </summary>
|
||||
public async Task<object> 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<Dictionary<string, object>>(resResult);
|
||||
var marketInfo = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
|
||||
return marketInfo.FindAll(x => x["coin"].ToString().Contains("USDT"));
|
||||
}
|
||||
case ExchangeWebReqType.BitgetMarket_Future:
|
||||
{
|
||||
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||||
var marketInfos = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
|
||||
|
||||
var newList = new List<MarketInfo>();
|
||||
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<Dictionary<string, object>>(resResult);
|
||||
var datas = JsonConvert.DeserializeObject<List<List<object>>>(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<Dictionary<string, object>>(resResult);
|
||||
return JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(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<Dictionary<string, object>>(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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 기존 IndicatorProcess.GenerateSignString을 그대로 이식.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
71
Bitget_Server/Infrastructure/Bitget/BitgetRateLimiter.cs
Normal file
71
Bitget_Server/Infrastructure/Bitget/BitgetRateLimiter.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace Bitget_Server.Infrastructure.Bitget;
|
||||
|
||||
/// <summary>
|
||||
/// Bitget REST API에 대한 클라이언트 사이드 Rate Limiter.
|
||||
/// Sliding window 방식으로 1초당 N회 호출 제한을 강제한다.
|
||||
/// 모든 외부 호출은 await AcquireAsync(...) 후에 수행되어야 한다.
|
||||
/// </summary>
|
||||
public class BitgetRateLimiter
|
||||
{
|
||||
private readonly int _maxRequestsPerSecond;
|
||||
private readonly Queue<long> _requestTicks = new();
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly ILogger<BitgetRateLimiter> _logger;
|
||||
|
||||
public BitgetRateLimiter(int maxRequestsPerSecond, ILogger<BitgetRateLimiter> logger)
|
||||
{
|
||||
// 0 또는 음수 → 무제한 모드. 양수 → 슬라이딩 윈도우 적용.
|
||||
_maxRequestsPerSecond = maxRequestsPerSecond <= 0 ? int.MaxValue : maxRequestsPerSecond;
|
||||
_logger = logger;
|
||||
|
||||
if (_maxRequestsPerSecond == int.MaxValue)
|
||||
_logger.LogWarning("BitgetRateLimiter: RateLimitPerSecond <= 0 — 무제한 모드로 동작합니다.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 호출 슬롯이 확보될 때까지 대기한 뒤 즉시 반환한다.
|
||||
/// 무제한 모드(_maxRequestsPerSecond == int.MaxValue) 에서는 즉시 반환.
|
||||
/// </summary>
|
||||
public async Task AcquireAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_maxRequestsPerSecond == int.MaxValue) return;
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
int totalWaitedMs = 0;
|
||||
while (true)
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
// 1초 이전 요청 기록 제거
|
||||
while (_requestTicks.Count > 0 && now - _requestTicks.Peek() >= 1000)
|
||||
_requestTicks.Dequeue();
|
||||
|
||||
if (_requestTicks.Count < _maxRequestsPerSecond)
|
||||
{
|
||||
_requestTicks.Enqueue(now);
|
||||
if (totalWaitedMs >= 100)
|
||||
_logger.LogInformation("Rate limit throttled {WaitMs}ms (queue={Count}/{Max})",
|
||||
totalWaitedMs, _requestTicks.Count, _maxRequestsPerSecond);
|
||||
return;
|
||||
}
|
||||
|
||||
// 가장 오래된 요청이 1초 경과할 때까지 대기
|
||||
var waitMs = 1000 - (int)(now - _requestTicks.Peek()) + 1;
|
||||
if (waitMs < 1) waitMs = 1;
|
||||
totalWaitedMs += waitMs;
|
||||
|
||||
_logger.LogDebug("Rate limit hit. Waiting {WaitMs}ms (queue={Count}/{Max})",
|
||||
waitMs, _requestTicks.Count, _maxRequestsPerSecond);
|
||||
|
||||
_gate.Release();
|
||||
try { await Task.Delay(waitMs, ct).ConfigureAwait(false); }
|
||||
finally { await _gate.WaitAsync(ct).ConfigureAwait(false); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
237
Bitget_Server/Infrastructure/Bitget/BitgetWebSocketClient.cs
Normal file
237
Bitget_Server/Infrastructure/Bitget/BitgetWebSocketClient.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using Bitget_Server.Application.Options;
|
||||
using Bitget_Server.Application.State;
|
||||
using Bitget_Server.Domain;
|
||||
using Bitget_Server.Domain.Enums;
|
||||
using Bitget_Server.Domain.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Bitget_Server.Infrastructure.Bitget;
|
||||
|
||||
/// <summary>
|
||||
/// 기존 WinForms WebConnection의 WebSocket 로직(BitGetWebSocektStart, closeWebSocket, ContinuePing,
|
||||
/// ValidateJSON, CalculateDataList)을 ASP.NET Core 환경으로 이식.
|
||||
/// 싱글톤으로 등록되어 백그라운드 서비스 + Controller에서 공유됨.
|
||||
/// </summary>
|
||||
public class BitgetWebSocketClient : IAsyncDisposable
|
||||
{
|
||||
private readonly MarketState _state;
|
||||
private readonly ILogger<BitgetWebSocketClient> _logger;
|
||||
private readonly BitgetOptions _options;
|
||||
|
||||
private ClientWebSocket _webSocket;
|
||||
private CancellationTokenSource _cts;
|
||||
private Task _receiveLoopTask;
|
||||
private Task _pingLoopTask;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<Dictionary<string, object>> _webSocketDataList = new();
|
||||
|
||||
public bool IsConnected =>
|
||||
_webSocket != null && _webSocket.State == WebSocketState.Open;
|
||||
|
||||
public BitgetWebSocketClient(
|
||||
MarketState state,
|
||||
IOptions<BitgetOptions> options,
|
||||
ILogger<BitgetWebSocketClient> logger)
|
||||
{
|
||||
_state = state;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 BitGetWebSocektStart와 동일 — subscribe 메시지(dic)를 만들어 연결 시작.
|
||||
/// </summary>
|
||||
public async Task StartAsync(BitgetWebSocketType wsType, Dictionary<string, object> subscribeDic, CancellationToken ct = default)
|
||||
{
|
||||
await StopAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_webSocket = new ClientWebSocket();
|
||||
|
||||
try
|
||||
{
|
||||
await _webSocket.ConnectAsync(new Uri(BitgetEndpoints.WebSocketPublic), _cts.Token).ConfigureAwait(false);
|
||||
_logger.LogInformation("WebSocket 연결 성공 ({Type})", wsType);
|
||||
|
||||
var jsonData = JsonConvert.SerializeObject(subscribeDic);
|
||||
await SendMessageAsync(jsonData, _cts.Token).ConfigureAwait(false);
|
||||
|
||||
_pingLoopTask = Task.Run(() => PingLoopAsync(_cts.Token));
|
||||
_receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "WebSocket 연결 실패");
|
||||
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var tempStrData = string.Empty;
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
byte[] buffer = new byte[1024];
|
||||
var result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct).ConfigureAwait(false);
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
|
||||
if (message.Contains("pong"))
|
||||
{
|
||||
var parts = message.Split(new[] { "pong" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
message = string.Concat(parts);
|
||||
}
|
||||
|
||||
// JSON 조각 병합
|
||||
if (tempStrData.Length == 0)
|
||||
{
|
||||
if (!ValidateJSON(message)) { tempStrData = message; continue; }
|
||||
}
|
||||
else
|
||||
{
|
||||
tempStrData += message;
|
||||
if (!ValidateJSON(tempStrData)) continue;
|
||||
message = tempStrData;
|
||||
tempStrData = string.Empty;
|
||||
}
|
||||
|
||||
if (message.Contains("{\"action"))
|
||||
{
|
||||
var splitData = message.Split(new[] { "{\"action" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var item in splitData)
|
||||
{
|
||||
var packet = "{\"action" + item;
|
||||
try
|
||||
{
|
||||
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(packet);
|
||||
if (datalist != null && datalist.TryGetValue("action", out var action) && action?.ToString() == "update")
|
||||
{
|
||||
lock (_lock) _webSocketDataList.Add(datalist);
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "WebSocket 수신 루프 오류");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PingLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(_options.WebSocketPingIntervalMs, ct).ConfigureAwait(false);
|
||||
await SendMessageAsync("ping", ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Ping 루프 종료");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync(string message, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(message);
|
||||
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("WebSocket 메시지 전송: {Message}", message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket 메시지 전송 실패");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidateJSON(string s)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (s.Contains("{\"action"))
|
||||
{
|
||||
var splitData = s.Split(new[] { "{\"action" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var item in splitData)
|
||||
{
|
||||
try { JToken.Parse("{\"action" + item); }
|
||||
catch (JsonReaderException) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
JToken.Parse(s);
|
||||
return true;
|
||||
}
|
||||
catch (JsonReaderException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 CalculateDataList 로직 이식.
|
||||
/// BackgroundService가 1초 주기로 호출하여 WebSocket 누적 데이터를 candleDataLists로 병합.
|
||||
/// </summary>
|
||||
public void CalculateDataList(BitgetWebSocketType bitgetWebSocketType, List<object> intervalList)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<Dictionary<string, object>> cloneList;
|
||||
lock (_lock)
|
||||
{
|
||||
cloneList = _webSocketDataList.Select(d => new Dictionary<string, object>(d)).ToList();
|
||||
_webSocketDataList.Clear();
|
||||
}
|
||||
|
||||
// 주의: 복잡한 캔들 병합 로직은 기존 WinForms 버전 그대로 유지 시
|
||||
// IndicatorProcess와 강하게 결합되므로 별도 서비스에서 처리하도록
|
||||
// 구조만 유지하고 세부 처리는 IndicatorProcessService로 위임한다.
|
||||
// (이 메서드 자체는 기존 WebConnection.CalculateDataList 진입점 역할)
|
||||
foreach (var item in cloneList)
|
||||
{
|
||||
// TODO: 필요 시 기존 시간대별 병합 로직 포팅
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "WebSocket 데이터 처리 중 오류");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
|
||||
if (_webSocket != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_webSocket.State == WebSocketState.Open)
|
||||
await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "WebSocket 연결 종료", ct).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
_webSocket.Dispose();
|
||||
_webSocket = null;
|
||||
}
|
||||
|
||||
_pingLoopTask = null;
|
||||
_receiveLoopTask = null;
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
79
Bitget_Server/Infrastructure/Utils/BaseSettings.cs
Normal file
79
Bitget_Server/Infrastructure/Utils/BaseSettings.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bitget_Server.Infrastructure.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 파일에 자동 영속화되는 싱글톤 설정 베이스.
|
||||
/// 기존 WinForms BaseSettings<T> 로직을 유지하되, ASP.NET Core 환경에서는
|
||||
/// 파일 저장 경로를 /data 볼륨(컨테이너) 또는 AppContext.BaseDirectory로 사용한다.
|
||||
/// </summary>
|
||||
public class BaseSettings<T> where T : class, new()
|
||||
{
|
||||
private static readonly object lockObject = new object();
|
||||
private static readonly object fileLockObject = new object();
|
||||
private static T _instance;
|
||||
|
||||
/// <summary>
|
||||
/// 설정 파일을 저장할 디렉토리. Program.cs에서 시작 시 지정한다 (기본: AppContext.BaseDirectory).
|
||||
/// </summary>
|
||||
public static string StorageDirectory { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
public static T Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_instance == null)
|
||||
_instance = CreateInstance();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
protected static T CreateInstance()
|
||||
{
|
||||
try
|
||||
{
|
||||
var saveFilePath = GetFilePath();
|
||||
if (File.Exists(saveFilePath))
|
||||
{
|
||||
return JsonConvert.DeserializeObject<T>(File.ReadAllText(saveFilePath)) ?? new T();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상 시 기본값으로 복구
|
||||
}
|
||||
return new T();
|
||||
}
|
||||
|
||||
private static string GetFilePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(StorageDirectory);
|
||||
}
|
||||
catch { }
|
||||
return Path.Combine(StorageDirectory, typeof(T).Name + ".json");
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
lock (fileLockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(this, Formatting.Indented);
|
||||
File.WriteAllText(GetFilePath(), json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 무시 (로그는 상위 레이어에서)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SaveConfig 의 짧은 별칭.</summary>
|
||||
public void Save() => SaveConfig();
|
||||
}
|
||||
35
Bitget_Server/Infrastructure/Utils/Convertor.cs
Normal file
35
Bitget_Server/Infrastructure/Utils/Convertor.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Bitget_Server.Infrastructure.Utils;
|
||||
|
||||
public static class Convertor
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum의 Description Attribute 문자열을 가져온다.
|
||||
/// </summary>
|
||||
public static string GetEnumDescription(Enum source)
|
||||
{
|
||||
FieldInfo fi = source.GetType().GetField(source.ToString());
|
||||
if (fi == null) return source.ToString();
|
||||
|
||||
var att = (DescriptionAttribute)fi.GetCustomAttribute(typeof(DescriptionAttribute));
|
||||
return att != null ? att.Description : source.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DateTime -> Unix 타임스탬프(초 단위, KST 9시 기준). 기존 WinForms 로직과 동일.
|
||||
/// </summary>
|
||||
public static long GetDateTimeToUnixtimeStamp(DateTime datetime)
|
||||
{
|
||||
return (long)(datetime - new DateTime(1970, 1, 1, 9, 0, 0)).TotalSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unix ms 타임스탬프 -> DateTime (KST 9시 기준).
|
||||
/// </summary>
|
||||
public static DateTime GetUnixtimeStampToDateTime(long unixStamptime)
|
||||
{
|
||||
return new DateTime(1970, 1, 1, 9, 0, 0).AddMilliseconds(unixStamptime);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user