단순 이관

This commit is contained in:
2026-04-20 20:27:42 +09:00
parent e6c530ff48
commit 83fb215eec
100 changed files with 4209 additions and 1467 deletions

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

View 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();
}
}
}

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

View File

@@ -0,0 +1,79 @@
using Newtonsoft.Json;
namespace Bitget_Server.Infrastructure.Utils;
/// <summary>
/// JSON 파일에 자동 영속화되는 싱글톤 설정 베이스.
/// 기존 WinForms BaseSettings&lt;T&gt; 로직을 유지하되, 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();
}

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