feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBackend.Util;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -16,35 +18,45 @@ namespace FinlyticBackend.Controllers;
|
||||
[Route("api/v1/user/trades")]
|
||||
public class UserTradesController : ControllerBase
|
||||
{
|
||||
private readonly WebMqttClient _mqttClient;
|
||||
private readonly BackendMqttBridge _mqttClient;
|
||||
private readonly ILogger<UserTradesController> _logger;
|
||||
|
||||
public UserTradesController(WebMqttClient mqttClient, ILogger<UserTradesController> logger)
|
||||
public UserTradesController(BackendMqttBridge mqttClient, ILogger<UserTradesController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
|
||||
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, parsed as a
|
||||
/// <see cref="Guid"/> exactly like the global user-validation middleware in
|
||||
/// <c>FinlyticBackend/Program.cs</c> (see line ~146). There is deliberately no pseudo-identity fallback
|
||||
/// (e.g. a shared "default_user" string): every trade in FinlyticEngine is tenant-scoped by
|
||||
/// <c>EngineTradeEntity.UserId</c>, so a request whose identity cannot be established must be rejected
|
||||
/// with 401 rather than silently attributed to a placeholder user that could leak or merge data across
|
||||
/// tenants.
|
||||
/// </summary>
|
||||
private string GetUserIdFromClaims()
|
||||
/// <exception cref="UnauthorizedAccessException">
|
||||
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
|
||||
/// parseable <see cref="Guid"/>.
|
||||
/// </exception>
|
||||
private Guid GetUserIdFromClaims()
|
||||
{
|
||||
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue("sub")
|
||||
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue("sub")
|
||||
?? User.FindFirstValue("nameid");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(claimUserId))
|
||||
if (Guid.TryParse(claimUserId, out var userId))
|
||||
{
|
||||
return claimUserId;
|
||||
return userId;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[UserTradesController] Claim NameIdentifier not found for authenticated request. Falling back to default_user.");
|
||||
return "default_user";
|
||||
_logger.LogWarning("[UserTradesController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
|
||||
throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of trades for the current authenticated user (including global proposals).
|
||||
/// Retrieves a list of active trades or proposals from FinlyticEngine.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
|
||||
@@ -53,116 +65,186 @@ public class UserTradesController : ControllerBase
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "MQTT Broker disconnected" });
|
||||
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
|
||||
}
|
||||
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// FIX: UserId explizit an GetTradesRequest übergeben!
|
||||
var request = new GetTradesRequest(isin, status, userId);
|
||||
|
||||
var trades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(trades ?? new List<TradeProposalDto>());
|
||||
if (string.Equals(status, "Proposed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Proposals are system-wide opportunities, not owned by any user, so no UserId is attached here.
|
||||
var req = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
|
||||
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
"engine_GetProposals", req, TimeSpan.FromSeconds(5));
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
else
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
var req = new GetActiveTradesRequest(UserId: userId, Mode: null);
|
||||
var trades = await _mqttClient.SendRpcRequestAsync<List<ActiveTradeDto>, GetActiveTradesRequest>(
|
||||
"engine_GetTrades", req, TimeSpan.FromSeconds(5));
|
||||
return Ok(trades ?? new List<ActiveTradeDto>());
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve user trades via MQTT RPC.");
|
||||
_logger.LogError(ex, "Failed to retrieve trades from FinlyticEngine.");
|
||||
return StatusCode(500, new { error = "Internal server error while fetching trades" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public endpoint to retrieve active global proposals for guest users.
|
||||
/// </summary>
|
||||
[HttpGet("/api/v1/trades/public")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetPublicProposals([FromQuery] string? isin = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "MQTT Broker disconnected" });
|
||||
}
|
||||
|
||||
// Status = Proposed für anonyme Öffentliche Anfragen
|
||||
var request = new GetTradesRequest(isin, "Proposed", UserId: null);
|
||||
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
|
||||
return Ok(new List<TradeProposalDto>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a proposed trade and assigns it to the current user's portfolio.
|
||||
/// Accepts a trade proposal and converts it into an actively tracked trade in FinlyticEngine, owned by the
|
||||
/// authenticated caller.
|
||||
/// </summary>
|
||||
/// <param name="dto">
|
||||
/// The acceptance payload sent by the client (see <c>TradeRepository.acceptTrade</c> in FinlyticApp),
|
||||
/// carrying the proposal identifier (<see cref="FinlyticCore.Models.Trades.TradeAcceptanceDto.TradeId"/>)
|
||||
/// plus any user-adjusted position sizing, leverage and fee details. Any <c>userId</c> supplied by the
|
||||
/// client is discarded; the acceptance is always attributed to the identity from the JWT.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The resulting <see cref="ActiveTradeDto"/> on success, or a diagnostic error response if
|
||||
/// FinlyticEngine is unreachable or rejects the request (e.g. proposal expired, not found, or already
|
||||
/// accepted by this same user).
|
||||
/// </returns>
|
||||
[HttpPost("accept")]
|
||||
public async Task<IActionResult> AcceptTrade([FromBody] TradeAcceptanceDto request)
|
||||
public async Task<IActionResult> AcceptTrade([FromBody] FinlyticCore.Models.Trades.TradeAcceptanceDto dto)
|
||||
{
|
||||
if (dto == null || string.IsNullOrWhiteSpace(dto.TradeId) || !Guid.TryParse(dto.TradeId, out var proposalId))
|
||||
{
|
||||
return BadRequest(new { error = "A valid proposal id ('tradeId') is required." });
|
||||
}
|
||||
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
// The acceptance is always attributed to the authenticated caller, never to a client-supplied value.
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
var acceptRequest = new AcceptTradeProposalRequest(
|
||||
UserId: userId,
|
||||
ProposalId: proposalId,
|
||||
ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice,
|
||||
Quantity: dto.Quantity ?? dto.PositionSize);
|
||||
|
||||
var acceptedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, AcceptTradeProposalRequest>(
|
||||
"engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10));
|
||||
|
||||
if (acceptedTrade != null)
|
||||
{
|
||||
return StatusCode(503, new { error = "MQTT Broker disconnected" });
|
||||
return Ok(acceptedTrade);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AnalysisId) && string.IsNullOrWhiteSpace(request.TradeId))
|
||||
{
|
||||
return BadRequest(new { error = "AnalysisId or TradeId is required" });
|
||||
}
|
||||
|
||||
// FIX: UserId felsenfest aus den authentifizierten Claims überschreiben
|
||||
request.UserId = GetUserIdFromClaims();
|
||||
|
||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, TradeAcceptanceDto>(
|
||||
"trades_Accept",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Fallback Fire-and-Forget
|
||||
await _mqttClient.PublishAsync($"finlytic/trades/accept/{request.Isin}", request);
|
||||
return Ok(new { status = "Accepted", analysisId = request.AnalysisId, tradeId = request.TradeId, userId = request.UserId });
|
||||
return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to accept trade proposal via MQTT RPC.");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Cannot accept proposal {ProposalId}: rejected by FinlyticEngine (expired, not found, or already accepted by this user).", proposalId);
|
||||
return Conflict(new { error = "The proposal is no longer available or has already been accepted by this user." });
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Malformed RPC response while accepting proposal {ProposalId}.", proposalId);
|
||||
return StatusCode(502, new { error = "FinlyticEngine returned an unexpected response while accepting the trade." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an active trade.
|
||||
/// Manually opens a trade in FinlyticEngine with no backing proposal (e.g. the user enters a position in
|
||||
/// the Web UI that was never evaluated/scored by FinlyticEngine). This is the alternative to
|
||||
/// <see cref="AcceptTrade"/>: a user either accepts an existing proposal or creates a trade from scratch,
|
||||
/// and both paths end up with one user-owned <see cref="ActiveTradeDto"/>.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/close")]
|
||||
public async Task<IActionResult> CloseTrade(string id, [FromBody] CloseTradeRequest? request)
|
||||
/// <param name="request">
|
||||
/// The manual trade payload from the client. Any <c>userId</c> it carries is discarded; the trade is
|
||||
/// always attributed to the identity from the JWT.
|
||||
/// </param>
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> CreateManualTrade([FromBody] CreateManualTradeRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Problem(title: "A request body is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The trade is always attributed to the authenticated caller, never to a client-supplied value.
|
||||
var userId = GetUserIdFromClaims();
|
||||
var manualTradeRequest = request with { UserId = userId };
|
||||
|
||||
var trade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, CreateManualTradeRequest>(
|
||||
"engine_CreateManualTrade", manualTradeRequest, TimeSpan.FromSeconds(10));
|
||||
|
||||
if (trade != null)
|
||||
{
|
||||
return Ok(trade);
|
||||
}
|
||||
|
||||
return Problem(title: "FinlyticEngine did not confirm the manual trade in time.", statusCode: StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Rejected manual trade creation: invalid request payload.");
|
||||
return Problem(title: "The manual trade payload is invalid.", detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Malformed RPC response while creating a manual trade.");
|
||||
return Problem(title: "FinlyticEngine returned an unexpected response while creating the manual trade.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create manual trade via FinlyticEngine.");
|
||||
return Problem(title: "Internal server error while creating the manual trade.", statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an active trade via FinlyticEngine. The trade must be owned by the authenticated caller;
|
||||
/// FinlyticEngine enforces this server-side and reports an unknown/foreign trade identically (Rules.md
|
||||
/// multi-tenancy requirement), so this action never leaks whether a trade ID belongs to another user.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/close")]
|
||||
public async Task<IActionResult> CloseTrade(Guid id, [FromBody] CloseEngineTradeRequest? request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "MQTT Broker disconnected" });
|
||||
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
|
||||
}
|
||||
|
||||
var closeReq = request ?? new CloseTradeRequest { UserExitPrice = 100.0m, CloseReason = "UserManualClose" };
|
||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
|
||||
$"trades_Close/{id}",
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// Any UserId/TradeId supplied by the client in the body is discarded and replaced with the
|
||||
// authenticated identity and the route value, so a caller cannot target another user's trade.
|
||||
var closeReq = (request ?? new CloseEngineTradeRequest(UserId: userId, TradeId: id, ClosePrice: 0m, Reason: "UserManualClose"))
|
||||
with { UserId = userId, TradeId = id };
|
||||
|
||||
var result = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, CloseEngineTradeRequest>(
|
||||
"engine_CloseTrade",
|
||||
closeReq,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
@@ -171,45 +253,16 @@ public class UserTradesController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
return Ok(new { status = "Closed", tradeId = id });
|
||||
return NotFound(new { error = $"Trade {id} not found." });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to close trade {Id} via MQTT RPC.", id);
|
||||
_logger.LogError(ex, "Failed to close trade {Id} via FinlyticEngine.", id);
|
||||
return StatusCode(500, new { error = "Internal server error while closing trade" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a proposed trade.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/reject")]
|
||||
public async Task<IActionResult> RejectTrade(string id, [FromBody] CloseTradeRequest? request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "MQTT Broker disconnected" });
|
||||
}
|
||||
|
||||
var closeReq = request ?? new CloseTradeRequest { CloseReason = "UserRejected" };
|
||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
|
||||
$"trades_Reject/{id}",
|
||||
closeReq,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
return Ok(new { status = "Rejected", tradeId = id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reject trade {Id} via MQTT RPC.", id);
|
||||
return StatusCode(500, new { error = "Internal server error while rejecting trade" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user