Implements solution testing callback
Introduces a callback mechanism for the testing module to update solution status. This change includes: - New DTOs for tester state and error codes. - A new API endpoint for receiving callbacks from the testing module. - Validation for the callback request. - Logic to update the solution status, including state, error code, and test progress. - Generation and validation of a single-use callback token for security. - Status composition based on tester state and results. The previous `UpdateSolutionStatusRequest` endpoint and model are removed in favor of the new callback approach.
This commit is contained in:
30
LiquidCode/Api/Submits/Dto/TesterModels.cs
Normal file
30
LiquidCode/Api/Submits/Dto/TesterModels.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Состояние выполнения решения в модуле тестирования
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TesterState
|
||||
{
|
||||
Waiting,
|
||||
Compiling,
|
||||
Testing,
|
||||
Done
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки, возвращаемый модулем тестирования
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TesterErrorCode
|
||||
{
|
||||
None,
|
||||
CompileError,
|
||||
RuntimeError,
|
||||
MemoryError,
|
||||
TimeLimitError,
|
||||
IncorrectAnswer,
|
||||
UnknownError
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Валидатор для обратных вызовов от тестирующего модуля
|
||||
/// </summary>
|
||||
public sealed class TesterCallbackRequestValidator : AbstractValidator<TesterCallbackRequest>
|
||||
{
|
||||
public TesterCallbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.SubmitId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Submit ID must be greater than 0");
|
||||
|
||||
RuleFor(x => x.AmountOfTests)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Amount of tests must be non-negative");
|
||||
|
||||
RuleFor(x => x.CurrentTest)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Current test index must be non-negative")
|
||||
.LessThanOrEqualTo(x => x.AmountOfTests)
|
||||
.WithMessage("Current test cannot exceed total amount of tests")
|
||||
.When(x => x.AmountOfTests > 0);
|
||||
|
||||
RuleFor(x => x.Message)
|
||||
.MaximumLength(512)
|
||||
.WithMessage("Message must not exceed 512 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Message));
|
||||
}
|
||||
}
|
||||
16
LiquidCode/Api/Submits/Requests/TesterCallbackRequest.cs
Normal file
16
LiquidCode/Api/Submits/Requests/TesterCallbackRequest.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LiquidCode.Api.Submits.Dto;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Модель запроса обратного вызова от тестирующего модуля
|
||||
/// </summary>
|
||||
public sealed record TesterCallbackRequest(
|
||||
[property: JsonPropertyName("SubmitId")] long SubmitId,
|
||||
[property: JsonPropertyName("State")] TesterState State,
|
||||
[property: JsonPropertyName("ErrorCode")] TesterErrorCode ErrorCode,
|
||||
[property: JsonPropertyName("Message")] string? Message,
|
||||
[property: JsonPropertyName("CurrentTest")] int CurrentTest,
|
||||
[property: JsonPropertyName("AmountOfTests")] int AmountOfTests
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Валидатор для запросов обновления статуса решения
|
||||
/// </summary>
|
||||
public class UpdateSolutionStatusRequestValidator : AbstractValidator<UpdateSolutionStatusRequest>
|
||||
{
|
||||
public UpdateSolutionStatusRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.SubmissionId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Submission ID must be greater than 0");
|
||||
|
||||
RuleFor(x => x.VerdictCode)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Verdict code must be non-negative")
|
||||
.LessThanOrEqualTo(10)
|
||||
.WithMessage("Verdict code must be between 0 and 10");
|
||||
|
||||
RuleFor(x => x.TestCase)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Test case number must be non-negative")
|
||||
.When(x => x.TestCase.HasValue);
|
||||
|
||||
RuleFor(x => x.TimeUsed)
|
||||
.Length(1, 50)
|
||||
.WithMessage("Time used must be between 1 and 50 characters")
|
||||
.Matches(@"^\d+(\.\d+)?\s*m?s$")
|
||||
.WithMessage("Time used must be in format like '100ms', '1.5s', '500'")
|
||||
.When(x => !string.IsNullOrEmpty(x.TimeUsed));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Модель запроса для обновления статуса решения (вызывается модулем тестирования)
|
||||
/// </summary>
|
||||
public record UpdateSolutionStatusRequest(
|
||||
[Required] int SubmissionId,
|
||||
[Required] int VerdictCode,
|
||||
int? TestCase = null,
|
||||
string? TimeUsed = null
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiquidCode.Api.Submits.Dto;
|
||||
using LiquidCode.Infrastructure.Database.Entities;
|
||||
|
||||
namespace LiquidCode.Api.Submits.Responses;
|
||||
@@ -12,7 +13,12 @@ public record SolutionResponse(
|
||||
string LanguageVersion,
|
||||
string SourceCode,
|
||||
string Status,
|
||||
DateTime Time
|
||||
DateTime Time,
|
||||
TesterState TesterState,
|
||||
TesterErrorCode TesterErrorCode,
|
||||
string? TesterMessage,
|
||||
int CurrentTest,
|
||||
int AmountOfTests
|
||||
)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -25,6 +31,11 @@ public record SolutionResponse(
|
||||
entity.LanguageVersion,
|
||||
entity.SourceCode,
|
||||
entity.Status,
|
||||
entity.Time
|
||||
entity.Time,
|
||||
entity.TestingState,
|
||||
entity.TestingErrorCode,
|
||||
entity.TestingMessage,
|
||||
entity.CurrentTest,
|
||||
entity.AmountOfTests
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using LiquidCode.Api.Submits.Requests;
|
||||
using LiquidCode.Api.Submits.Responses;
|
||||
using LiquidCode.Domain.Services.Submits;
|
||||
using LiquidCode.Infrastructure.External.TestingModule;
|
||||
using LiquidCode.Shared.Constants;
|
||||
using LiquidCode.Shared.Extensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiquidCode.Api.Submits;
|
||||
|
||||
@@ -13,8 +19,18 @@ namespace LiquidCode.Api.Submits;
|
||||
/// </summary>
|
||||
[Route("submits")]
|
||||
[ApiController]
|
||||
public class SubmitController(ISubmitService submitService, TestingHttpClient testingClient) : ControllerBase
|
||||
public class SubmitController(
|
||||
ISubmitService submitService,
|
||||
TestingHttpClient testingClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<SubmitController> logger) : ControllerBase
|
||||
{
|
||||
private const string CallbackRouteName = "SubmitTesterCallback";
|
||||
private readonly ISubmitService _submitService = submitService;
|
||||
private readonly TestingHttpClient _testingClient = testingClient;
|
||||
private readonly IConfiguration _configuration = configuration;
|
||||
private readonly ILogger<SubmitController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет решение для миссии
|
||||
/// </summary>
|
||||
@@ -28,7 +44,7 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var solution = await submitService.SubmitSolutionAsync(
|
||||
var solution = await _submitService.SubmitSolutionAsync(
|
||||
request.MissionId,
|
||||
userId,
|
||||
request.SourceCode,
|
||||
@@ -41,8 +57,32 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
if (solution == null)
|
||||
return BadRequest("Solution submission failed. Mission may not exist or language is not supported.");
|
||||
|
||||
// Отправить в модуль тестирования асинхронно (запустить и забыть)
|
||||
_ = testingClient.PostData(solution.Id, request.MissionId, request.SourceCode, request.Language);
|
||||
try
|
||||
{
|
||||
var callbackToken = solution.CallbackToken;
|
||||
if (string.IsNullOrWhiteSpace(callbackToken))
|
||||
throw new InvalidOperationException("Callback token is not generated.");
|
||||
|
||||
var missionKey = solution.Mission?.S3PrivateKey;
|
||||
if (string.IsNullOrWhiteSpace(missionKey))
|
||||
throw new InvalidOperationException("Mission package key is missing.");
|
||||
|
||||
var testerPayload = new SubmitForTesterModel(
|
||||
solution.Id,
|
||||
request.MissionId,
|
||||
request.Language,
|
||||
request.LanguageVersion,
|
||||
request.SourceCode,
|
||||
BuildPackageUrl(missionKey!),
|
||||
BuildCallbackUrl(callbackToken));
|
||||
|
||||
await _testingClient.SubmitAsync(testerPayload, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to dispatch solution {SolutionId} to testing module", solution.Id);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "Failed to dispatch solution to testing module.");
|
||||
}
|
||||
|
||||
return Ok(SolutionResponse.FromEntity(solution));
|
||||
}
|
||||
@@ -57,7 +97,7 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
if (!User.TryGetUserId(out var userId))
|
||||
return Unauthorized("User ID not found in claims.");
|
||||
|
||||
var submissions = await submitService.GetUserSubmissionsAsync(userId, cancellationToken);
|
||||
var submissions = await _submitService.GetUserSubmissionsAsync(userId, cancellationToken);
|
||||
|
||||
var result = submissions.Select(SubmissionResponse.FromEntity);
|
||||
|
||||
@@ -74,7 +114,7 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
if (!User.TryGetUserId(out var userId))
|
||||
return Unauthorized("User ID not found in claims.");
|
||||
|
||||
var submission = await submitService.GetSubmissionAsync(id, cancellationToken);
|
||||
var submission = await _submitService.GetSubmissionAsync(id, cancellationToken);
|
||||
|
||||
if (submission == null || submission.User.Id != userId)
|
||||
return NotFound("Submission not found or access denied.");
|
||||
@@ -92,7 +132,7 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
if (!User.TryGetUserId(out var userId))
|
||||
return Unauthorized("User ID not found in claims.");
|
||||
|
||||
var submissions = await submitService.GetUserSubmissionsAsync(userId, cancellationToken);
|
||||
var submissions = await _submitService.GetUserSubmissionsAsync(userId, cancellationToken);
|
||||
|
||||
var filtered = submissions
|
||||
.Where(sub => sub.Solution.Mission.Id == missionId)
|
||||
@@ -103,49 +143,71 @@ public class SubmitController(ISubmitService submitService, TestingHttpClient te
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет статус решения (вызывается модулем тестирования)
|
||||
/// Получает обновление статуса решения от тестирующего модуля
|
||||
/// </summary>
|
||||
[HttpPost("update-status")]
|
||||
public async Task<IActionResult> UpdateSolutionStatus([FromBody] UpdateSolutionStatusRequest request, CancellationToken cancellationToken)
|
||||
[HttpPost("testing/callback/{token}", Name = CallbackRouteName)]
|
||||
public async Task<IActionResult> ReceiveTesterCallback([FromRoute] string token, [FromBody] TesterCallbackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var verdictMessage = FormatVerdictMessage(request.VerdictCode, request.TestCase);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return BadRequest("Callback token is required.");
|
||||
|
||||
var result = await submitService.UpdateSolutionStatusAsync(request.SubmissionId, verdictMessage, cancellationToken);
|
||||
if (result == null)
|
||||
return NotFound("Solution not found.");
|
||||
if (request.SubmitId <= 0 || request.SubmitId > int.MaxValue)
|
||||
return BadRequest("SubmitId value is out of supported range.");
|
||||
|
||||
return Accepted(SolutionResponse.FromEntity(result));
|
||||
var updateResult = await _submitService.UpdateTesterStatusAsync(
|
||||
(int)request.SubmitId,
|
||||
token,
|
||||
request.State,
|
||||
request.ErrorCode,
|
||||
request.Message,
|
||||
request.CurrentTest,
|
||||
request.AmountOfTests,
|
||||
cancellationToken);
|
||||
|
||||
return updateResult.Status switch
|
||||
{
|
||||
TesterCallbackUpdateStatus.Success when updateResult.Solution != null => Accepted(SolutionResponse.FromEntity(updateResult.Solution)),
|
||||
TesterCallbackUpdateStatus.NotFound => NotFound("Solution not found."),
|
||||
TesterCallbackUpdateStatus.TokenMismatch => StatusCode(StatusCodes.Status403Forbidden, "Invalid or expired callback token."),
|
||||
TesterCallbackUpdateStatus.Error => StatusCode(StatusCodes.Status500InternalServerError, "Failed to update solution status."),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unexpected tester callback processing result.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Форматирует сообщение вердикта для статуса решения
|
||||
/// </summary>
|
||||
private static string FormatVerdictMessage(int verdictCode, int? testCase)
|
||||
private string BuildPackageUrl(string s3Key)
|
||||
{
|
||||
var verdictMessages = new[]
|
||||
{
|
||||
"Accepted",
|
||||
"Wrong answer",
|
||||
"Time limit",
|
||||
"Memory limit",
|
||||
"Internal error",
|
||||
"Runtime error",
|
||||
"Compilation error"
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(s3Key))
|
||||
throw new InvalidOperationException("Mission package key is not configured.");
|
||||
|
||||
if (verdictCode == -1)
|
||||
return "Running";
|
||||
var endpoint = _configuration[ConfigurationKeys.S3Endpoint] ??
|
||||
throw new InvalidOperationException($"Configuration key '{ConfigurationKeys.S3Endpoint}' is not configured.");
|
||||
var bucket = _configuration[ConfigurationKeys.S3PrivateBucket] ??
|
||||
throw new InvalidOperationException($"Configuration key '{ConfigurationKeys.S3PrivateBucket}' is not configured.");
|
||||
|
||||
var message = verdictCode >= 0 && verdictCode < verdictMessages.Length
|
||||
? verdictMessages[verdictCode]
|
||||
: "Unknown verdict";
|
||||
var encodedKey = string.Join('/', s3Key
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
|
||||
if (testCase.HasValue && verdictCode >= 1 && verdictCode <= 5)
|
||||
message += $" #{testCase}";
|
||||
return $"{endpoint.TrimEnd('/')}/{bucket}/{encodedKey}";
|
||||
}
|
||||
|
||||
return message;
|
||||
private string BuildCallbackUrl(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new InvalidOperationException("Callback token is not provided.");
|
||||
|
||||
if (!Request.Host.HasValue)
|
||||
throw new InvalidOperationException("Unable to determine request host for callback URL generation.");
|
||||
|
||||
var scheme = string.IsNullOrWhiteSpace(Request.Scheme) ? Uri.UriSchemeHttps : Request.Scheme;
|
||||
|
||||
var link = Url.RouteUrl(CallbackRouteName, values: new { token }, protocol: scheme, host: Request.Host.Value);
|
||||
if (string.IsNullOrWhiteSpace(link))
|
||||
throw new InvalidOperationException("Unable to build callback URL.");
|
||||
|
||||
return link;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiquidCode.Api.Submits.Dto;
|
||||
using LiquidCode.Infrastructure.Database.Entities;
|
||||
|
||||
namespace LiquidCode.Domain.Services.Submits;
|
||||
@@ -54,12 +55,43 @@ public interface ISubmitService
|
||||
Task<IEnumerable<DbUserSubmission>> GetMissionSubmissionsAsync(int missionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет статус решения
|
||||
/// Применяет обновление статуса решения от тестирующего модуля
|
||||
/// </summary>
|
||||
/// <param name="solutionId">ID решения</param>
|
||||
/// <param name="status">Новый статус</param>
|
||||
/// <param name="solutionId">Идентификатор решения</param>
|
||||
/// <param name="callbackToken">Одноразовый токен обратного вызова</param>
|
||||
/// <param name="state">Новое состояние выполнения</param>
|
||||
/// <param name="errorCode">Информация об ошибке выполнения</param>
|
||||
/// <param name="message">Сообщение от тестирующего модуля</param>
|
||||
/// <param name="currentTest">Номер текущего теста</param>
|
||||
/// <param name="amountOfTests">Общее количество тестов</param>
|
||||
/// <param name="cancellationToken">Токен отмены</param>
|
||||
/// <returns>Обновленное решение или null, если не найдено</returns>
|
||||
Task<DbSolution?> UpdateSolutionStatusAsync(int solutionId, string status, CancellationToken cancellationToken = default);
|
||||
/// <returns>Результат применения обновления</returns>
|
||||
Task<TesterCallbackUpdateResult> UpdateTesterStatusAsync(
|
||||
int solutionId,
|
||||
string callbackToken,
|
||||
TesterState state,
|
||||
TesterErrorCode errorCode,
|
||||
string? message,
|
||||
int currentTest,
|
||||
int amountOfTests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возможный исход применения обратного вызова тестирующего модуля
|
||||
/// </summary>
|
||||
public enum TesterCallbackUpdateStatus
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
TokenMismatch,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Результат применения обратного вызова тестирующего модуля
|
||||
/// </summary>
|
||||
public readonly record struct TesterCallbackUpdateResult(
|
||||
TesterCallbackUpdateStatus Status,
|
||||
DbSolution? Solution);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using LiquidCode.Infrastructure.Database.Entities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LiquidCode.Domain.Interfaces.Repositories;
|
||||
using LiquidCode.Infrastructure.Database.Entities;
|
||||
using LiquidCode.Api.Submits.Dto;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiquidCode.Domain.Services.Submits;
|
||||
@@ -172,7 +176,13 @@ public class SubmitService : ISubmitService
|
||||
Language = language,
|
||||
LanguageVersion = languageVersion,
|
||||
SourceCode = sourceCode,
|
||||
Status = "submitted",
|
||||
Status = ComposeStatus(TesterState.Waiting, TesterErrorCode.None, null, 0, 0),
|
||||
TestingState = TesterState.Waiting,
|
||||
TestingErrorCode = TesterErrorCode.None,
|
||||
TestingMessage = null,
|
||||
CurrentTest = 0,
|
||||
AmountOfTests = 0,
|
||||
CallbackToken = GenerateCallbackToken(),
|
||||
Time = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -237,7 +247,15 @@ public class SubmitService : ISubmitService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DbSolution?> UpdateSolutionStatusAsync(int solutionId, string status, CancellationToken cancellationToken = default)
|
||||
public async Task<TesterCallbackUpdateResult> UpdateTesterStatusAsync(
|
||||
int solutionId,
|
||||
string callbackToken,
|
||||
TesterState state,
|
||||
TesterErrorCode errorCode,
|
||||
string? message,
|
||||
int currentTest,
|
||||
int amountOfTests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -245,20 +263,95 @@ public class SubmitService : ISubmitService
|
||||
if (solution == null)
|
||||
{
|
||||
_logger.LogWarning("Solution not found: {SolutionId}", solutionId);
|
||||
return null;
|
||||
return new TesterCallbackUpdateResult(TesterCallbackUpdateStatus.NotFound, null);
|
||||
}
|
||||
|
||||
solution.Status = status;
|
||||
// TODO: Реализовать метод обновления в репозитории
|
||||
if (string.IsNullOrWhiteSpace(solution.CallbackToken) || !IsTokenMatch(solution.CallbackToken, callbackToken))
|
||||
{
|
||||
_logger.LogWarning("Callback token mismatch for solution {SolutionId}", solutionId);
|
||||
return new TesterCallbackUpdateResult(TesterCallbackUpdateStatus.TokenMismatch, null);
|
||||
}
|
||||
|
||||
var normalizedAmount = Math.Max(amountOfTests, 0);
|
||||
var normalizedCurrent = Math.Clamp(currentTest, 0, normalizedAmount > 0 ? normalizedAmount : int.MaxValue);
|
||||
var trimmedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
|
||||
|
||||
solution.TestingState = state;
|
||||
solution.TestingErrorCode = errorCode;
|
||||
solution.TestingMessage = trimmedMessage;
|
||||
solution.CurrentTest = normalizedCurrent;
|
||||
solution.AmountOfTests = normalizedAmount;
|
||||
solution.Status = ComposeStatus(state, errorCode, trimmedMessage, normalizedCurrent, normalizedAmount);
|
||||
solution.CallbackToken = null;
|
||||
|
||||
await _submitRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Solution status updated: SolutionId={SolutionId}, Status={Status}", solutionId, status);
|
||||
return solution;
|
||||
_logger.LogInformation(
|
||||
"Solution tester status updated: SolutionId={SolutionId}, State={State}, ErrorCode={ErrorCode}, CurrentTest={CurrentTest}, TotalTests={TotalTests}",
|
||||
solutionId,
|
||||
state,
|
||||
errorCode,
|
||||
normalizedCurrent,
|
||||
normalizedAmount);
|
||||
return new TesterCallbackUpdateResult(TesterCallbackUpdateStatus.Success, solution);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating solution status: {SolutionId}", solutionId);
|
||||
return null;
|
||||
_logger.LogError(ex, "Error updating tester status: {SolutionId}", solutionId);
|
||||
return new TesterCallbackUpdateResult(TesterCallbackUpdateStatus.Error, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComposeStatus(
|
||||
TesterState state,
|
||||
TesterErrorCode errorCode,
|
||||
string? message,
|
||||
int currentTest,
|
||||
int amountOfTests)
|
||||
{
|
||||
var baseStatus = state switch
|
||||
{
|
||||
TesterState.Waiting => "Waiting",
|
||||
TesterState.Compiling => "Compiling",
|
||||
TesterState.Testing => amountOfTests > 0
|
||||
? $"Testing {Math.Clamp(currentTest, 0, amountOfTests)}/{amountOfTests}"
|
||||
: "Testing",
|
||||
TesterState.Done => errorCode switch
|
||||
{
|
||||
TesterErrorCode.None => "Accepted",
|
||||
TesterErrorCode.CompileError => "Compilation error",
|
||||
TesterErrorCode.RuntimeError => "Runtime error",
|
||||
TesterErrorCode.MemoryError => "Memory limit exceeded",
|
||||
TesterErrorCode.TimeLimitError => "Time limit exceeded",
|
||||
TesterErrorCode.IncorrectAnswer => "Wrong answer",
|
||||
_ => "Unknown error"
|
||||
},
|
||||
_ => "Unknown state"
|
||||
};
|
||||
|
||||
return string.IsNullOrWhiteSpace(message)
|
||||
? baseStatus
|
||||
: $"{baseStatus}: {message}";
|
||||
}
|
||||
|
||||
private static string GenerateCallbackToken()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsTokenMatch(string storedToken, string providedToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedToken) || string.IsNullOrWhiteSpace(providedToken))
|
||||
return false;
|
||||
|
||||
var storedBytes = Encoding.UTF8.GetBytes(storedToken.Trim().ToLowerInvariant());
|
||||
var providedBytes = Encoding.UTF8.GetBytes(providedToken.Trim().ToLowerInvariant());
|
||||
|
||||
if (storedBytes.Length != providedBytes.Length)
|
||||
return false;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(storedBytes, providedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using LiquidCode.Api.Submits.Dto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiquidCode.Infrastructure.Database.Entities;
|
||||
@@ -30,6 +31,20 @@ public class DbSolution : ITimestamped
|
||||
[StringLength(32)]
|
||||
[Required]
|
||||
public string Status { get; set; } = null!;
|
||||
|
||||
public TesterState TestingState { get; set; } = TesterState.Waiting;
|
||||
|
||||
public TesterErrorCode TestingErrorCode { get; set; } = TesterErrorCode.None;
|
||||
|
||||
[StringLength(512)]
|
||||
public string? TestingMessage { get; set; }
|
||||
|
||||
public int CurrentTest { get; set; }
|
||||
|
||||
public int AmountOfTests { get; set; }
|
||||
|
||||
[StringLength(128)]
|
||||
public string? CallbackToken { get; set; }
|
||||
|
||||
public DateTime Time { get; init; }
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ public class SubmitRepository : ISubmitRepository
|
||||
|
||||
public async Task<DbSolution?> GetSolutionAsync(int solutionId, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.Solutions
|
||||
.Include(s => s.Mission)
|
||||
.FirstOrDefaultAsync(s => s.Id == solutionId, cancellationToken);
|
||||
|
||||
public async Task AddSolutionAsync(DbSolution solution, CancellationToken cancellationToken = default) =>
|
||||
|
||||
16
LiquidCode/Infrastructure/External/TestingModule/SubmitForTesterModel.cs
vendored
Normal file
16
LiquidCode/Infrastructure/External/TestingModule/SubmitForTesterModel.cs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LiquidCode.Infrastructure.External.TestingModule;
|
||||
|
||||
/// <summary>
|
||||
/// DTO, отправляемая во внешний тестирующий модуль
|
||||
/// </summary>
|
||||
public sealed record SubmitForTesterModel(
|
||||
[property: JsonPropertyName("Id")] long Id,
|
||||
[property: JsonPropertyName("MissionId")] long MissionId,
|
||||
[property: JsonPropertyName("Language")] string Language,
|
||||
[property: JsonPropertyName("LanguageVersion")] string LanguageVersion,
|
||||
[property: JsonPropertyName("SourceCode")] string SourceCode,
|
||||
[property: JsonPropertyName("PackageUrl")] string PackageUrl,
|
||||
[property: JsonPropertyName("CallbackUrl")] string CallbackUrl
|
||||
);
|
||||
@@ -1,31 +1,28 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NuGet.Protocol;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiquidCode.Infrastructure.External.TestingModule;
|
||||
|
||||
public class TestingHttpClient(string endpointUrl)
|
||||
public class TestingHttpClient(string endpointUrl, ILogger<TestingHttpClient> logger)
|
||||
{
|
||||
private HttpClient _client = new()
|
||||
private readonly HttpClient _client = new()
|
||||
{
|
||||
BaseAddress = new Uri(endpointUrl),
|
||||
};
|
||||
private readonly ILogger<TestingHttpClient> _logger = logger;
|
||||
|
||||
public async Task PostData(int id, int missionId, string sourceCode, string language)
|
||||
public async Task SubmitAsync(SubmitForTesterModel payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using StringContent jsonContent = new(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
problemId = missionId,
|
||||
sourceCode,
|
||||
language
|
||||
}),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
var response = await _client.PostAsJsonAsync("api/submit", payload, cancellationToken);
|
||||
|
||||
await _client.PostAsync(
|
||||
"api/submit",
|
||||
jsonContent);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogError("Testing module returned {StatusCode} for submit {SubmitId}: {Body}",
|
||||
(int)response.StatusCode,
|
||||
payload.Id,
|
||||
string.IsNullOrWhiteSpace(content) ? "<empty>" : content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
1084
LiquidCode/Migrations/20251027180200_AddTesterStatusFields.Designer.cs
generated
Normal file
1084
LiquidCode/Migrations/20251027180200_AddTesterStatusFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LiquidCode.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTesterStatusFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "amount_of_tests",
|
||||
table: "solutions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "callback_token",
|
||||
table: "solutions",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "current_test",
|
||||
table: "solutions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "testing_error_code",
|
||||
table: "solutions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "testing_message",
|
||||
table: "solutions",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "testing_state",
|
||||
table: "solutions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "amount_of_tests",
|
||||
table: "solutions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "callback_token",
|
||||
table: "solutions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "current_test",
|
||||
table: "solutions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "testing_error_code",
|
||||
table: "solutions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "testing_message",
|
||||
table: "solutions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "testing_state",
|
||||
table: "solutions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,10 +575,23 @@ namespace LiquidCode.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AmountOfTests")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("amount_of_tests");
|
||||
|
||||
b.Property<string>("CallbackToken")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("callback_token");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CurrentTest")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("current_test");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
@@ -607,6 +620,19 @@ namespace LiquidCode.Migrations
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<int>("TestingErrorCode")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("testing_error_code");
|
||||
|
||||
b.Property<string>("TestingMessage")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("testing_message");
|
||||
|
||||
b.Property<int>("TestingState")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("testing_state");
|
||||
|
||||
b.Property<DateTime>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using LiquidCode;
|
||||
@@ -21,6 +22,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -68,7 +70,10 @@ if (builder.Configuration[ConfigurationKeys.MigrateOnlyFlag] == "1")
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
// Настроить разрешающую политику CORS, чтобы браузеры могли отправлять запросы с
|
||||
// пользовательскими заголовками (например, Content-Type) и предварительный OPTIONS запрос
|
||||
@@ -84,8 +89,13 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddS3Buckets(builder.Configuration);
|
||||
builder.Services.AddSingleton(new TestingHttpClient(builder.Configuration[ConfigurationKeys.TestingModuleUrl] ??
|
||||
throw new ArgumentNullException(ConfigurationKeys.TestingModuleUrl)));
|
||||
builder.Services.AddSingleton<TestingHttpClient>(provider =>
|
||||
{
|
||||
var endpoint = builder.Configuration[ConfigurationKeys.TestingModuleUrl] ??
|
||||
throw new ArgumentNullException(ConfigurationKeys.TestingModuleUrl);
|
||||
var logger = provider.GetRequiredService<ILogger<TestingHttpClient>>();
|
||||
return new TestingHttpClient(endpoint, logger);
|
||||
});
|
||||
|
||||
// Добавить репозитории
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
|
||||
Reference in New Issue
Block a user