feat(Backend): update API gateway and websocket hubs
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAssets.Models;
|
||||
using FinlyticAssets.Util;
|
||||
using FinlyticBackend.Database;
|
||||
using FinlyticBackend.Util;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Assets;
|
||||
using FinlyticCore.Dtos.Fundamentals;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBackend.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for asset search results (AOT-compliant).
|
||||
/// </summary>
|
||||
public record AssetSearchResultDto(
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("isin")] string Isin,
|
||||
[property: JsonPropertyName("image")] string Image
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for discovery assets (AOT-compliant).
|
||||
/// </summary>
|
||||
public record DiscoveryAssetResponseDto(
|
||||
[property: JsonPropertyName("isin")] string Isin,
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("category")] string Category,
|
||||
[property: JsonPropertyName("image")] string Image,
|
||||
[property: JsonPropertyName("tags")] List<string> Tags
|
||||
);
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/assets")]
|
||||
public class AssetsController : ControllerBase
|
||||
{
|
||||
private readonly WebMqttClient _mqttClient;
|
||||
private readonly BackendDbContext _dbContext;
|
||||
private readonly ILogger<AssetsController> _logger;
|
||||
|
||||
private static List<AssetIndex>? _cachedIndexAssets = null;
|
||||
|
||||
private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" rx="30" fill="#1E293B"/>
|
||||
<path d="M 30 65 L 45 45 L 60 55 L 75 35" fill="none" stroke="#10B981" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="75" cy="35" r="5" fill="#06B6D4"/>
|
||||
</svg>
|
||||
""");
|
||||
|
||||
public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger<AssetsController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt den AssetIndex direkt aus der gemounteten index.json.
|
||||
/// </summary>
|
||||
public static List<AssetIndex> LoadAssetsFromIndexJson()
|
||||
{
|
||||
if (_cachedIndexAssets != null && _cachedIndexAssets.Count > 0)
|
||||
{
|
||||
return _cachedIndexAssets;
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(Volumes.IndexRelativePath, "index.json");
|
||||
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = System.IO.File.ReadAllText(filePath);
|
||||
var assets = JsonSerializer.Deserialize<List<AssetIndex>>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex);
|
||||
if (assets != null && assets.Count > 0)
|
||||
{
|
||||
_cachedIndexAssets = assets;
|
||||
return assets;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durchsucht Assets im Index.
|
||||
/// </summary>
|
||||
[HttpGet("search")]
|
||||
public IActionResult SearchAssets([FromQuery] string q = "")
|
||||
{
|
||||
string query = q?.Trim() ?? "";
|
||||
|
||||
var allAssets = LoadAssetsFromIndexJson();
|
||||
|
||||
var localMatches = !string.IsNullOrWhiteSpace(query)
|
||||
? allAssets.Where(a =>
|
||||
(!string.IsNullOrEmpty(a.Name) && a.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(!string.IsNullOrEmpty(a.Isin) && a.Isin.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
).ToList()
|
||||
: allAssets.Take(50).ToList();
|
||||
|
||||
var results = localMatches.Select(a => new AssetSearchResultDto(
|
||||
Symbol: a.Isin,
|
||||
Name: a.Name,
|
||||
Isin: a.Isin,
|
||||
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}"
|
||||
)).Take(50).ToList();
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-Endpunkt via RPC mit Fallback auf die index.json.
|
||||
/// </summary>
|
||||
[HttpGet("discovery")]
|
||||
public async Task<IActionResult> GetDiscoveryAssets([FromQuery] int limit = 15)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_mqttClient.IsConnected)
|
||||
{
|
||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDiscoveryAssetsRequest>(
|
||||
"assets_GetDiscovery",
|
||||
new GetDiscoveryAssetsRequest(limit),
|
||||
TimeSpan.FromSeconds(3)
|
||||
);
|
||||
|
||||
if (rpcResult != null && rpcResult.Count > 0)
|
||||
{
|
||||
var formatted = rpcResult.Select(a => new DiscoveryAssetResponseDto(
|
||||
Isin: a.Isin,
|
||||
Symbol: a.Isin,
|
||||
Name: a.Name,
|
||||
Type: a.Type,
|
||||
Category: a.InstrumentCategory,
|
||||
Image: !string.IsNullOrWhiteSpace(a.ImageId) ? a.ImageId : $"/api/v1/assets/logo/{a.Isin}",
|
||||
Tags: a.Tags?.Select(t => t.Name).ToList() ?? new List<string>()
|
||||
)).ToList();
|
||||
|
||||
return Ok(formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[AssetsController] RPC discovery fehlgeschlagen. Fallback auf index.json.");
|
||||
}
|
||||
|
||||
var allAssets = LoadAssetsFromIndexJson();
|
||||
var fallback = allAssets.Take(limit).Select(a => new DiscoveryAssetResponseDto(
|
||||
Isin: a.Isin,
|
||||
Symbol: a.Isin,
|
||||
Name: a.Name,
|
||||
Type: "stock",
|
||||
Category: "Stock",
|
||||
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}",
|
||||
Tags: new List<string>()
|
||||
)).ToList();
|
||||
|
||||
return Ok(fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Fundamentaldaten per ISIN.
|
||||
/// </summary>
|
||||
[HttpGet("{isin}/fundamentals")]
|
||||
public async Task<IActionResult> GetFundamentals(
|
||||
[FromRoute] string isin,
|
||||
[FromQuery] string? ticker = null,
|
||||
[FromQuery] bool forceRefresh = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
string? cleanTicker = ticker?.Trim().ToUpperInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cleanIsin))
|
||||
{
|
||||
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_mqttClient.IsConnected)
|
||||
{
|
||||
var requestDto = new IsinRequest(
|
||||
Isin: cleanIsin,
|
||||
Ticker: cleanTicker,
|
||||
ForceRefresh: forceRefresh
|
||||
);
|
||||
|
||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
|
||||
"fundamentals_Get",
|
||||
requestDto,
|
||||
TimeSpan.FromSeconds(forceRefresh ? 15 : 8)
|
||||
);
|
||||
|
||||
if (rpcResult != null)
|
||||
{
|
||||
return Ok(rpcResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ApiGateway] RPC fundamentals_Get für ISIN '{Isin}' fehlgeschlagen.", cleanIsin);
|
||||
}
|
||||
|
||||
return NotFound(new { message = $"Keine Fundamentaldaten für ISIN '{cleanIsin}' vorhanden." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Technische Analyse per ISIN.
|
||||
/// </summary>
|
||||
[HttpGet("{isin}/technicals")]
|
||||
public async Task<IActionResult> GetTechnicals(
|
||||
[FromRoute] string isin,
|
||||
[FromQuery] bool forceRefresh = false,
|
||||
[FromQuery] string? ticker = null)
|
||||
{
|
||||
string normalizedSymbol = isin.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalizedSymbol))
|
||||
{
|
||||
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_mqttClient.IsConnected)
|
||||
{
|
||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis",
|
||||
new IsinRequest(normalizedSymbol, ticker, forceRefresh),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
if (rpcResult != null)
|
||||
{
|
||||
return Ok(rpcResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "RPC ta_GetAnalysis für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
|
||||
}
|
||||
|
||||
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
|
||||
/// </summary>
|
||||
[HttpGet("logo/{isin}")]
|
||||
public async Task<IActionResult> GetAssetLogo([FromRoute] string isin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin)) return NotFound();
|
||||
|
||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
|
||||
// Path Traversal Guard
|
||||
string safeFileName = string.Concat(cleanIsin.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + ".svg";
|
||||
string logoPath = Path.Combine(Volumes.LogosRelativePath, safeFileName);
|
||||
|
||||
if (System.IO.File.Exists(logoPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] localData = await System.IO.File.ReadAllBytesAsync(logoPath);
|
||||
return File(localData, "image/svg+xml");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Fallback: SVG Placeholder
|
||||
return File(PlaceholderSvgBytes, "image/svg+xml");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user