feat(Backend): update API gateway and websocket hubs
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBackend.Database;
|
||||
using FinlyticBackend.Entities;
|
||||
using FinlyticBackend.Util;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Assets;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBackend.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// DTO representing a user's favorite asset.
|
||||
/// </summary>
|
||||
public record FavoriteAssetDto(
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("isin")] string Isin,
|
||||
[property: JsonPropertyName("image")] string Image,
|
||||
[property: JsonPropertyName("currentPrice")]
|
||||
double CurrentPrice = 0.0,
|
||||
[property: JsonPropertyName("dailyChangePercent")]
|
||||
double DailyChangePercent = 0.0
|
||||
);
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/user/favorites")]
|
||||
public class UserFavoritesController : ControllerBase
|
||||
{
|
||||
private readonly BackendDbContext _dbContext;
|
||||
private readonly WebMqttClient _mqttClient;
|
||||
private readonly ILogger<UserFavoritesController> _logger;
|
||||
|
||||
public UserFavoritesController(
|
||||
BackendDbContext dbContext,
|
||||
WebMqttClient mqttClient,
|
||||
ILogger<UserFavoritesController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the user's favorite assets with parallelized RPC queries for maximum performance.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUserFavorites(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetUserId(out var userId))
|
||||
{
|
||||
return Unauthorized(new { message = "Invalid or expired user session." });
|
||||
}
|
||||
|
||||
var userFavorites = await _dbContext.UserFavoriteAssets
|
||||
.AsNoTracking()
|
||||
.Where(f => f.UserId == userId)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (userFavorites.Count == 0)
|
||||
{
|
||||
return Ok(new List<FavoriteAssetDto>());
|
||||
}
|
||||
|
||||
// Parallele RPC-Abfragen für alle Favoriten gleichzeitig vorbereiten
|
||||
var tasks = userFavorites.Select(async fav =>
|
||||
{
|
||||
string cleanIsin = fav.Isin.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(cleanIsin)) return null;
|
||||
|
||||
string querySymbol = !string.IsNullOrWhiteSpace(fav.SelectedTicker)
|
||||
? fav.SelectedTicker.Trim().ToUpperInvariant()
|
||||
: cleanIsin;
|
||||
|
||||
double currentPrice = 0.0;
|
||||
double dailyChangePercent = 0.0;
|
||||
FavoriteAssetDto? resolvedAsset = null;
|
||||
|
||||
// 1. Live Price & Asset-Details parallel via MQTT RPC abfragen
|
||||
var livePriceTask = FetchLivePriceAsync(querySymbol);
|
||||
var assetDetailsTask = FetchAssetDetailsAsync(cleanIsin, fav.SelectedTicker);
|
||||
|
||||
await Task.WhenAll(livePriceTask, assetDetailsTask);
|
||||
|
||||
var livePrice = await livePriceTask;
|
||||
if (livePrice.HasValue)
|
||||
{
|
||||
currentPrice = livePrice.Value.Price;
|
||||
dailyChangePercent = livePrice.Value.ChangePercent;
|
||||
}
|
||||
|
||||
resolvedAsset = await assetDetailsTask;
|
||||
|
||||
// 2. Fallback auf lokalen Index, falls Asset-Details über RPC fehlschlagen
|
||||
if (resolvedAsset == null)
|
||||
{
|
||||
var allAssets = AssetsController.LoadAssetsFromIndexJson();
|
||||
var matched = allAssets.FirstOrDefault(a =>
|
||||
a.Isin.Equals(cleanIsin, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.Equals(cleanIsin, StringComparison.OrdinalIgnoreCase));
|
||||
if (matched != null)
|
||||
{
|
||||
string isinCode = matched.Isin;
|
||||
string symbolCode = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker : isinCode;
|
||||
string name = string.IsNullOrWhiteSpace(matched.Name) ? isinCode : matched.Name;
|
||||
string image = !string.IsNullOrWhiteSpace(matched.Image)
|
||||
? matched.Image
|
||||
: $"/api/v1/logo/{isinCode}";
|
||||
resolvedAsset = new FavoriteAssetDto(symbolCode, name, isinCode, image, currentPrice,
|
||||
dailyChangePercent);
|
||||
}
|
||||
else
|
||||
{
|
||||
string symbolCode = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker : cleanIsin;
|
||||
resolvedAsset = new FavoriteAssetDto(
|
||||
symbolCode,
|
||||
cleanIsin,
|
||||
cleanIsin,
|
||||
$"/api/v1/logo/{cleanIsin}",
|
||||
currentPrice,
|
||||
dailyChangePercent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAsset;
|
||||
});
|
||||
|
||||
var resolvedList = await Task.WhenAll(tasks);
|
||||
|
||||
// Deduplizierung
|
||||
var result = new List<FavoriteAssetDto>();
|
||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in resolvedList)
|
||||
{
|
||||
if (asset == null) continue;
|
||||
|
||||
string dedupKey = string.IsNullOrWhiteSpace(asset.Isin) ? asset.Symbol : asset.Isin;
|
||||
string canonicalKey = dedupKey.Trim().ToUpperInvariant();
|
||||
|
||||
if (seenKeys.Add(canonicalKey))
|
||||
{
|
||||
seenKeys.Add(asset.Name.Trim().ToUpperInvariant());
|
||||
result.Add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the selected ticker for a favorite asset.
|
||||
/// </summary>
|
||||
[HttpPost("{symbol}/ticker")]
|
||||
public async Task<IActionResult> UpdateFavoriteTicker(string symbol, [FromQuery] string ticker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetUserId(out var userId))
|
||||
{
|
||||
return Unauthorized(new { message = "Invalid or expired user session." });
|
||||
}
|
||||
|
||||
string inputQuery = symbol.Trim();
|
||||
if (string.IsNullOrWhiteSpace(inputQuery) || string.IsNullOrWhiteSpace(ticker))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid symbol or ticker." });
|
||||
}
|
||||
|
||||
string canonicalIsin = ResolveCanonicalIsin(inputQuery);
|
||||
|
||||
var favorite = await _dbContext.UserFavoriteAssets
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.UserId == userId && (f.Isin == canonicalIsin || f.Isin == inputQuery.ToUpperInvariant()),
|
||||
cancellationToken);
|
||||
|
||||
if (favorite != null)
|
||||
{
|
||||
favorite.SelectedTicker = ticker.Trim().ToUpperInvariant();
|
||||
_dbContext.UserFavoriteAssets.Update(favorite);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { success = true, isin = canonicalIsin, selectedTicker = favorite.SelectedTicker });
|
||||
}
|
||||
|
||||
return NotFound(new { error = "Asset is not in user favorites." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the favorite status of an asset for the user.
|
||||
/// </summary>
|
||||
[HttpPost("{symbol}")]
|
||||
public async Task<IActionResult> ToggleFavorite(string symbol, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetUserId(out var userId))
|
||||
{
|
||||
return Unauthorized(new { message = "Invalid or expired user session." });
|
||||
}
|
||||
|
||||
string inputQuery = symbol.Trim();
|
||||
if (string.IsNullOrWhiteSpace(inputQuery))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid asset identifier." });
|
||||
}
|
||||
|
||||
string canonicalIsin = ResolveCanonicalIsin(inputQuery);
|
||||
var allAssets = AssetsController.LoadAssetsFromIndexJson();
|
||||
var matched = allAssets.FirstOrDefault(a => a.Isin.Equals(inputQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.Equals(inputQuery, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
string upperInput = inputQuery.ToUpperInvariant();
|
||||
string upperMatchedName = matched != null ? matched.Name.ToUpperInvariant() : string.Empty;
|
||||
|
||||
var existingMatches = await _dbContext.UserFavoriteAssets
|
||||
.Where(f => f.UserId == userId && (
|
||||
f.Isin == canonicalIsin ||
|
||||
f.Isin == upperInput ||
|
||||
(upperMatchedName != "" && f.Isin == upperMatchedName)
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
bool isFavorite;
|
||||
if (existingMatches.Count > 0)
|
||||
{
|
||||
_dbContext.UserFavoriteAssets.RemoveRange(existingMatches);
|
||||
isFavorite = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbContext.UserFavoriteAssets.Add(new UserFavoriteAssetEntity
|
||||
{
|
||||
UserId = userId,
|
||||
Isin = canonicalIsin,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
isFavorite = true;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
string assetName = matched?.Name ?? canonicalIsin;
|
||||
string imageUrl = matched != null && !string.IsNullOrWhiteSpace(matched.Image)
|
||||
? matched.Image
|
||||
: $"/api/v1/logo/{canonicalIsin}";
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
symbol = canonicalIsin,
|
||||
name = assetName,
|
||||
isin = canonicalIsin,
|
||||
image = imageUrl,
|
||||
isFavorite = isFavorite
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an asset from the user's favorites.
|
||||
/// </summary>
|
||||
[HttpDelete("{symbol}")]
|
||||
public async Task<IActionResult> RemoveFavorite(string symbol, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetUserId(out var userId))
|
||||
{
|
||||
return Unauthorized(new { message = "Invalid or expired user session." });
|
||||
}
|
||||
|
||||
string inputQuery = symbol.Trim();
|
||||
if (string.IsNullOrWhiteSpace(inputQuery))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid asset identifier." });
|
||||
}
|
||||
|
||||
string canonicalIsin = ResolveCanonicalIsin(inputQuery);
|
||||
|
||||
var existingMatches = await _dbContext.UserFavoriteAssets
|
||||
.Where(f => f.UserId == userId && (f.Isin == canonicalIsin || f.Isin == inputQuery.ToUpperInvariant()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (existingMatches.Count > 0)
|
||||
{
|
||||
_dbContext.UserFavoriteAssets.RemoveRange(existingMatches);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Ok(new { message = $"Removed {canonicalIsin} from favorites." });
|
||||
}
|
||||
|
||||
private async Task<(double Price, double ChangePercent)?> FetchLivePriceAsync(string querySymbol)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected) return null;
|
||||
|
||||
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
||||
"tr_GetLivePrice",
|
||||
new IsinRequest(querySymbol),
|
||||
TimeSpan.FromSeconds(1.5)
|
||||
);
|
||||
|
||||
if (livePriceDto != null)
|
||||
{
|
||||
return ((double)livePriceDto.CurrentPrice, (double)livePriceDto.DailyChangePercent);
|
||||
}
|
||||
|
||||
// Fallback auf TA Analysis
|
||||
var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis",
|
||||
new IsinRequest(querySymbol),
|
||||
TimeSpan.FromSeconds(2)
|
||||
);
|
||||
|
||||
if (taResult?.Candles != null && taResult.Candles.Count > 0)
|
||||
{
|
||||
var last = taResult.Candles.Last();
|
||||
var first = taResult.Candles.First();
|
||||
double price = (double)Math.Round(last.Close, 2);
|
||||
double change = first.Open > 0
|
||||
? (double)Math.Round(((last.Close - first.Open) / first.Open) * 100m, 2)
|
||||
: 0.0;
|
||||
return (price, change);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<FavoriteAssetDto?> FetchAssetDetailsAsync(string cleanIsin, string? selectedTicker)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected) return null;
|
||||
|
||||
var rpcAssets = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetValidAssetRequest>(
|
||||
"assets_Get",
|
||||
new GetValidAssetRequest(cleanIsin),
|
||||
TimeSpan.FromSeconds(2.5)
|
||||
);
|
||||
|
||||
if (rpcAssets != null && rpcAssets.Count > 0)
|
||||
{
|
||||
var primary = rpcAssets.First();
|
||||
string isinCode = primary.Isin;
|
||||
string symbolCode = !string.IsNullOrWhiteSpace(selectedTicker) ? selectedTicker : isinCode;
|
||||
string name = string.IsNullOrWhiteSpace(primary.Name) ? isinCode : primary.Name;
|
||||
string image = !string.IsNullOrWhiteSpace(primary.ImageId)
|
||||
? primary.ImageId
|
||||
: $"/api/v1/logo/{isinCode}";
|
||||
return new FavoriteAssetDto(symbolCode, name, isinCode, image);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveCanonicalIsin(string inputQuery)
|
||||
{
|
||||
var allAssets = AssetsController.LoadAssetsFromIndexJson();
|
||||
var matched = allAssets.FirstOrDefault(a => a.Isin.Equals(inputQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.Equals(inputQuery, StringComparison.OrdinalIgnoreCase));
|
||||
return matched != null ? matched.Isin.ToUpperInvariant() : inputQuery.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private bool TryGetUserId(out Guid userId)
|
||||
{
|
||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
return Guid.TryParse(userIdStr, out userId) && userId != Guid.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user