Модуль репутации
This commit is contained in:
@@ -154,3 +154,7 @@ src/Modules/Order/Tests/obj/
|
|||||||
src/Modules/Collaboration/Tests/bin/
|
src/Modules/Collaboration/Tests/bin/
|
||||||
|
|
||||||
src/Modules/Collaboration/Tests/obj/
|
src/Modules/Collaboration/Tests/obj/
|
||||||
|
|
||||||
|
src/Modules/Reputation/Tests/obj/
|
||||||
|
|
||||||
|
src/Modules/Reputation/Tests/bin/
|
||||||
|
|||||||
+9
-1
@@ -12,6 +12,8 @@ using Nashel.Modules.Order.Infrastructure;
|
|||||||
using Nashel.Modules.Order.Presentation;
|
using Nashel.Modules.Order.Presentation;
|
||||||
using Nashel.Modules.Collaboration.Infrastructure;
|
using Nashel.Modules.Collaboration.Infrastructure;
|
||||||
using Nashel.Modules.Collaboration.Presentation;
|
using Nashel.Modules.Collaboration.Presentation;
|
||||||
|
using Nashel.Modules.Reputation.Infrastructure;
|
||||||
|
using Nashel.Modules.Reputation.Presentation;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -62,10 +64,15 @@ builder.Services.AddCatalogModule(builder.Configuration);
|
|||||||
// Регистрация модуля Geo
|
// Регистрация модуля Geo
|
||||||
builder.Services.AddGeoModule(builder.Configuration);
|
builder.Services.AddGeoModule(builder.Configuration);
|
||||||
|
|
||||||
// Регистрация модуля Order
|
|
||||||
// Регистрация модуля Collaboartion
|
// Регистрация модуля Collaboartion
|
||||||
builder.Services.AddCollaborationModule(builder.Configuration);
|
builder.Services.AddCollaborationModule(builder.Configuration);
|
||||||
|
|
||||||
|
// Регистрация модуля Order
|
||||||
|
builder.Services.AddOrderModule(builder.Configuration);
|
||||||
|
|
||||||
|
// Регистрация модуля Reputation
|
||||||
|
builder.Services.AddReputationModule(builder.Configuration);
|
||||||
|
|
||||||
// Настройка аутентификации
|
// Настройка аутентификации
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
@@ -106,5 +113,6 @@ app.MapCatalogEndpoints();
|
|||||||
app.MapGeoEndpoints();
|
app.MapGeoEndpoints();
|
||||||
app.MapOrderEndpoints();
|
app.MapOrderEndpoints();
|
||||||
app.MapCollaborationEndpoints();
|
app.MapCollaborationEndpoints();
|
||||||
|
app.MapReputationEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nashel.Modules.Geo.Infrastructure.Persistence;
|
||||||
|
using NetTopologySuite.Geometries;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Geo.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(GeoDbContext))]
|
||||||
|
[Migration("20260212130229_GeoInitial")]
|
||||||
|
partial class GeoInitial
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Geo.Domain.Aggregates.LiveStatus", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BusyUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Point>("Location")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("geography (point)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Location");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist");
|
||||||
|
|
||||||
|
b.ToTable("LiveStatuses", "geo");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Geo.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GeoInitial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nashel.Modules.Order.Infrastructure.Persistence;
|
||||||
|
using NetTopologySuite.Geometries;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Order.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OrderDbContext))]
|
||||||
|
[Migration("20260212130220_OrderInitial")]
|
||||||
|
partial class OrderInitial
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("ordering")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("CustomerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deadline")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SelectedPerformerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ServiceId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Orders", "ordering");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("PerformerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrderId");
|
||||||
|
|
||||||
|
b.ToTable("OrderApplications", "ordering");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<string>("Address")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("Address");
|
||||||
|
|
||||||
|
b1.Property<Point>("Point")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("geography (point)");
|
||||||
|
|
||||||
|
b1.HasKey("OrderId");
|
||||||
|
|
||||||
|
b1.HasIndex("Point");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
|
||||||
|
|
||||||
|
b1.ToTable("Orders", "ordering");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("OrderId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Location")
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
|
||||||
|
.WithMany("Applications")
|
||||||
|
.HasForeignKey("OrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("OrderApplicationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("PriceAmount");
|
||||||
|
|
||||||
|
b1.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(3)
|
||||||
|
.HasColumnType("character varying(3)")
|
||||||
|
.HasColumnName("PriceCurrency");
|
||||||
|
|
||||||
|
b1.HasKey("OrderApplicationId");
|
||||||
|
|
||||||
|
b1.ToTable("OrderApplications", "ordering");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("OrderApplicationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Price")
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Applications");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Order.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class OrderInitial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Добавление улик к спору.
|
||||||
|
/// </summary>
|
||||||
|
public record AddEvidenceCommand(Guid DisputeId, Guid UploaderId, string FileUrl) : IRequest;
|
||||||
|
|
||||||
|
public class AddEvidenceHandler : IRequestHandler<AddEvidenceCommand>
|
||||||
|
{
|
||||||
|
private readonly IDisputeRepository _repository;
|
||||||
|
|
||||||
|
public AddEvidenceHandler(IDisputeRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(AddEvidenceCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken);
|
||||||
|
if (dispute == null)
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence = DisputeEvidence.Create(
|
||||||
|
request.DisputeId,
|
||||||
|
request.UploaderId,
|
||||||
|
request.FileUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
dispute.AddEvidence(evidence);
|
||||||
|
|
||||||
|
await _repository.UpdateAsync(dispute, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Команда создания отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateReviewCommand(Guid OrderId, Guid AuthorId, Guid TargetId, int Rating, string Text) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreateReviewHandler : IRequestHandler<CreateReviewCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IReviewRepository _repository;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public CreateReviewHandler(IReviewRepository repository, IMediator mediator)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> Handle(CreateReviewCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// В реальном приложении здесь должна быть проверка существования заказа и ролей.
|
||||||
|
|
||||||
|
var review = Review.Create(
|
||||||
|
request.OrderId,
|
||||||
|
request.AuthorId,
|
||||||
|
request.TargetId,
|
||||||
|
request.Rating,
|
||||||
|
request.Text
|
||||||
|
);
|
||||||
|
|
||||||
|
await _repository.AddAsync(review, cancellationToken);
|
||||||
|
|
||||||
|
// Публикация доменных событий
|
||||||
|
foreach (var domainEvent in review.DomainEvents)
|
||||||
|
{
|
||||||
|
await _mediator.Publish(domainEvent, cancellationToken);
|
||||||
|
}
|
||||||
|
review.ClearDomainEvents();
|
||||||
|
|
||||||
|
return review.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Команда на открытие спора (арбитраж).
|
||||||
|
/// </summary>
|
||||||
|
public record OpenDisputeCommand(Guid OrderId, Guid InitiatorId, string Reason) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class OpenDisputeHandler : IRequestHandler<OpenDisputeCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IDisputeRepository _repository;
|
||||||
|
private readonly IPublisher _publisher;
|
||||||
|
|
||||||
|
public OpenDisputeHandler(IDisputeRepository repository, IPublisher publisher)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_publisher = publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> Handle(OpenDisputeCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// В реальном приложении: проверка существования заказа и роли инициатора.
|
||||||
|
|
||||||
|
var dispute = Dispute.Open(
|
||||||
|
request.OrderId,
|
||||||
|
request.InitiatorId,
|
||||||
|
request.Reason
|
||||||
|
);
|
||||||
|
|
||||||
|
await _repository.AddAsync(dispute, cancellationToken);
|
||||||
|
|
||||||
|
// Публикуем интеграционное событие для блокировки выплат в модуле Order
|
||||||
|
await _publisher.Publish(new Events.DisputeOpenedIntegrationEvent(dispute.Id, dispute.OrderId), cancellationToken);
|
||||||
|
|
||||||
|
return dispute.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Команда для разрешения спора модератором/администратором.
|
||||||
|
/// </summary>
|
||||||
|
public record ResolveDisputeCommand(Guid DisputeId, string ResolutionDetails) : IRequest;
|
||||||
|
|
||||||
|
public class ResolveDisputeHandler : IRequestHandler<ResolveDisputeCommand>
|
||||||
|
{
|
||||||
|
private readonly IDisputeRepository _repository;
|
||||||
|
|
||||||
|
public ResolveDisputeHandler(IDisputeRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(ResolveDisputeCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken);
|
||||||
|
if (dispute == null)
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispute.Resolve(request.ResolutionDetails);
|
||||||
|
|
||||||
|
await _repository.UpdateAsync(dispute, cancellationToken);
|
||||||
|
|
||||||
|
// Здесь можно было бы отправить еще одно интеграционное событие,
|
||||||
|
// например, чтобы разблокировать выплату стороне-победителю в модуле Order.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Интеграционное событие: Спор открыт.
|
||||||
|
/// Используется для уведомления других модулей (например, модуля Order для блокировки оплаты).
|
||||||
|
/// </summary>
|
||||||
|
public record DisputeOpenedIntegrationEvent(Guid DisputeId, Guid OrderId) : INotification;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Events;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Services;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обработчик события создания отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public class ReviewCreatedEventHandler : INotificationHandler<ReviewCreatedEvent>
|
||||||
|
{
|
||||||
|
private readonly RatingCalculator _ratingCalculator;
|
||||||
|
private readonly ILogger<ReviewCreatedEventHandler> _logger;
|
||||||
|
|
||||||
|
public ReviewCreatedEventHandler(RatingCalculator ratingCalculator, ILogger<ReviewCreatedEventHandler> logger)
|
||||||
|
{
|
||||||
|
_ratingCalculator = ratingCalculator;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(ReviewCreatedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Пересчет рейтинга для пользователя {UserId} после нового отзыва...", notification.TargetId);
|
||||||
|
|
||||||
|
var newAverageRating = await _ratingCalculator.CalculateAverage(notification.TargetId, cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Новый средний рейтинг пользователя {UserId}: {Rating}", notification.TargetId, newAverageRating);
|
||||||
|
|
||||||
|
// Здесь можно обновить кэш или таблицу профилей пользователей с их текущим рейтингом.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Application.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос получения отзывов по пользователю.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TargetId">ID пользователя.</param>
|
||||||
|
public record GetReviewsByTargetQuery(Guid TargetId) : IRequest<List<ReviewDto>>;
|
||||||
|
|
||||||
|
public record ReviewDto(Guid ReviewId, int Rating, string Text, string[] MediaUrls, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public class GetReviewsByTargetHandler : IRequestHandler<GetReviewsByTargetQuery, List<ReviewDto>>
|
||||||
|
{
|
||||||
|
private readonly IReviewRepository _repository;
|
||||||
|
|
||||||
|
public GetReviewsByTargetHandler(IReviewRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ReviewDto>> Handle(GetReviewsByTargetQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var reviews = await _repository.GetByTargetIdAsync(request.TargetId, cancellationToken);
|
||||||
|
|
||||||
|
return reviews.Select(r => new ReviewDto(
|
||||||
|
r.Id,
|
||||||
|
r.Rating,
|
||||||
|
r.Text,
|
||||||
|
r.MediaUrls.ToArray(),
|
||||||
|
r.CreatedAt
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Nashel.BuildingBlocks.Domain;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Агрегат Спора (Арбитража) по заказу.
|
||||||
|
/// </summary>
|
||||||
|
public class Dispute : AggregateRoot<Guid>
|
||||||
|
{
|
||||||
|
private DateTime _createdAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID заказа, по которому возник спор.
|
||||||
|
/// </summary>
|
||||||
|
public Guid OrderId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID инициатора спора (кто открыл).
|
||||||
|
/// </summary>
|
||||||
|
public Guid InitiatorId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Причина спора / Жалоба.
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Текущий статус спора.
|
||||||
|
/// </summary>
|
||||||
|
public DisputeStatus Status { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Список улик / доказательств (фото/видео).
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<DisputeEvidence> _evidences = new();
|
||||||
|
public IReadOnlyCollection<DisputeEvidence> Evidences => _evidences.AsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата открытия спора.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt => _createdAt;
|
||||||
|
|
||||||
|
// Для EF Core
|
||||||
|
private Dispute()
|
||||||
|
{
|
||||||
|
Reason = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dispute(Guid id, Guid orderId, Guid initiatorId, string reason)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
OrderId = orderId;
|
||||||
|
InitiatorId = initiatorId;
|
||||||
|
Reason = reason;
|
||||||
|
Status = DisputeStatus.Created;
|
||||||
|
_createdAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Открывает новый спор.
|
||||||
|
/// </summary>
|
||||||
|
public static Dispute Open(Guid orderId, Guid initiatorId, string reason)
|
||||||
|
{
|
||||||
|
var dispute = new Dispute(Guid.NewGuid(), orderId, initiatorId, reason);
|
||||||
|
// При создании сразу переводим в статус сбора улик
|
||||||
|
dispute.StartEvidenceCollection();
|
||||||
|
return dispute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Начинает этап сбора улик (72 часа на предоставление доказательств).
|
||||||
|
/// </summary>
|
||||||
|
public void StartEvidenceCollection()
|
||||||
|
{
|
||||||
|
Status = DisputeStatus.EvidenceCollection;
|
||||||
|
// Здесь можно было бы добавить Domain Event: DisputeEvidenceCollectionStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Добавляет улику (фото/видео) к спору.
|
||||||
|
/// </summary>
|
||||||
|
public void AddEvidence(DisputeEvidence evidence)
|
||||||
|
{
|
||||||
|
if (Status != DisputeStatus.EvidenceCollection)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Добавление улик возможно только на этапе сбора доказательств.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка времени 72 часа (можно сделать через Background Job,
|
||||||
|
// но здесь тоже проверим для надежности).
|
||||||
|
if (DateTime.UtcNow > _createdAt.AddHours(72))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Время для сбора улик истекло (72 часа).");
|
||||||
|
}
|
||||||
|
|
||||||
|
_evidences.Add(evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Переводит спор в статус ожидания решения модератора (после истечения времени сбора улик).
|
||||||
|
/// </summary>
|
||||||
|
public void MoveToPendingDecision()
|
||||||
|
{
|
||||||
|
if (Status != DisputeStatus.EvidenceCollection)
|
||||||
|
{
|
||||||
|
// Логика: можно перевести из Created, если сразу все предоставили?
|
||||||
|
// Допустим, только из EvidenceCollection.
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = DisputeStatus.PendingDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разрешает спор (администратором/модератором).
|
||||||
|
/// </summary>
|
||||||
|
public void Resolve(string resolutionDetails)
|
||||||
|
{
|
||||||
|
if (Status == DisputeStatus.Resolved)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Спор уже разрешен.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = DisputeStatus.Resolved;
|
||||||
|
// Здесь можно добавить Domain Event: DisputeResolvedEvent(Id, resolutionDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Nashel.BuildingBlocks.Domain;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Улика (фото/видео/документ) в споре.
|
||||||
|
/// </summary>
|
||||||
|
public class DisputeEvidence : Entity<Guid>
|
||||||
|
{
|
||||||
|
private DateTime _createdAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID спора.
|
||||||
|
/// </summary>
|
||||||
|
public Guid DisputeId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID пользователя, загрузившего улику.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UploaderId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка на файл (фото/видео).
|
||||||
|
/// </summary>
|
||||||
|
public string FileUrl { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тип файла (image, video, document).
|
||||||
|
/// </summary>
|
||||||
|
public string FileType { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата загрузки.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt => _createdAt;
|
||||||
|
|
||||||
|
// Для EF Core
|
||||||
|
private DisputeEvidence()
|
||||||
|
{
|
||||||
|
FileUrl = null!;
|
||||||
|
FileType = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisputeEvidence(Guid id, Guid disputeId, Guid uploaderId, string fileUrl, string fileType)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
DisputeId = disputeId;
|
||||||
|
UploaderId = uploaderId;
|
||||||
|
FileUrl = fileUrl;
|
||||||
|
FileType = fileType;
|
||||||
|
_createdAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DisputeEvidence Create(Guid disputeId, Guid uploaderId, string fileUrl, string fileType = "image")
|
||||||
|
{
|
||||||
|
return new DisputeEvidence(Guid.NewGuid(), disputeId, uploaderId, fileUrl, fileType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Nashel.BuildingBlocks.Domain;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Events;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отзыв о пользователе (исполнителе или заказчике).
|
||||||
|
/// </summary>
|
||||||
|
public class Review : AggregateRoot<Guid>
|
||||||
|
{
|
||||||
|
private DateTime _createdAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID заказа, к которому относится отзыв.
|
||||||
|
/// </summary>
|
||||||
|
public Guid OrderId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID автора отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public Guid AuthorId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID получателя отзыва (на кого отзыв).
|
||||||
|
/// </summary>
|
||||||
|
public Guid TargetId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оценка (рейтинг) от 1 до 5.
|
||||||
|
/// </summary>
|
||||||
|
public int Rating { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Текст отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public string Text { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Список ссылок на медиа-файлы (фото).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> MediaUrls { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Флаг авто-сгенерированного отзыва (если нет отзыва в течение 7 дней).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAutoGenerated { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата создания отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt => _createdAt;
|
||||||
|
|
||||||
|
// Для EF Core
|
||||||
|
private Review()
|
||||||
|
{
|
||||||
|
Text = null!;
|
||||||
|
MediaUrls = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Review(Guid id, Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
OrderId = orderId;
|
||||||
|
AuthorId = authorId;
|
||||||
|
TargetId = targetId;
|
||||||
|
Rating = rating;
|
||||||
|
Text = text;
|
||||||
|
IsAutoGenerated = isAutoGenerated;
|
||||||
|
MediaUrls = new List<string>();
|
||||||
|
_createdAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (rating < 1 || rating > 5)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static Review Create(Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated = false)
|
||||||
|
{
|
||||||
|
var review = new Review(Guid.NewGuid(), orderId, authorId, targetId, rating, text, isAutoGenerated);
|
||||||
|
review.AddDomainEvent(new ReviewCreatedEvent(review.Id, targetId, rating));
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Добавляет ссылки на медиа.
|
||||||
|
/// </summary>
|
||||||
|
public void AddMedia(List<string> urls)
|
||||||
|
{
|
||||||
|
// Можно проверить время редактирования
|
||||||
|
CheckIfEditable();
|
||||||
|
MediaUrls.AddRange(urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновляет текст и рейтинг.
|
||||||
|
/// </summary>
|
||||||
|
public void Update(string text, int rating)
|
||||||
|
{
|
||||||
|
CheckIfEditable();
|
||||||
|
|
||||||
|
if (rating < 1 || rating > 5)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
|
||||||
|
|
||||||
|
Text = text;
|
||||||
|
Rating = rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверяет, можно ли редактировать отзыв (доступно только в течение 3 дней).
|
||||||
|
/// </summary>
|
||||||
|
private void CheckIfEditable()
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow > _createdAt.AddDays(3))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Редактирование отзыва доступно только в течение 3 дней после создания.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Nashel.Modules.Reputation.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Статус спора (арбитража).
|
||||||
|
/// </summary>
|
||||||
|
public enum DisputeStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Спор создан, ожидание принятия.
|
||||||
|
/// </summary>
|
||||||
|
Created,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сбор улик (72 часа).
|
||||||
|
/// </summary>
|
||||||
|
EvidenceCollection,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание решения модератора.
|
||||||
|
/// </summary>
|
||||||
|
PendingDecision,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спор разрешен.
|
||||||
|
/// </summary>
|
||||||
|
Resolved
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Nashel.BuildingBlocks.Domain;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Событие создания отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public class ReviewCreatedEvent : BaseDomainEvent
|
||||||
|
{
|
||||||
|
public Guid ReviewId { get; }
|
||||||
|
public Guid TargetId { get; }
|
||||||
|
public int Rating { get; }
|
||||||
|
|
||||||
|
public ReviewCreatedEvent(Guid reviewId, Guid targetId, int rating)
|
||||||
|
{
|
||||||
|
ReviewId = reviewId;
|
||||||
|
TargetId = targetId;
|
||||||
|
Rating = rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Repositories
|
||||||
|
{
|
||||||
|
public interface IReviewRepository
|
||||||
|
{
|
||||||
|
Task AddAsync(Review review, CancellationToken cancellationToken = default);
|
||||||
|
Task<List<Review>> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default);
|
||||||
|
Task<Review?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
// Для пересчета среднего рейтинга можно использовать SQL-запрос, но здесь пока просто GetByTargetId
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IDisputeRepository
|
||||||
|
{
|
||||||
|
Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default);
|
||||||
|
Task<Dispute?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Domain.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сервис для расчета рейтинга пользователей.
|
||||||
|
/// </summary>
|
||||||
|
public class RatingCalculator
|
||||||
|
{
|
||||||
|
private readonly IReviewRepository _reviewRepository;
|
||||||
|
|
||||||
|
public RatingCalculator(IReviewRepository reviewRepository)
|
||||||
|
{
|
||||||
|
_reviewRepository = reviewRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вычисляет средний рейтинг пользователя на основе всех его отзывов.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Средний рейтинг (double) или 0, если отзывов нет.</returns>
|
||||||
|
public async Task<double> CalculateAverage(Guid targetId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var reviews = await _reviewRepository.GetByTargetIdAsync(targetId, cancellationToken);
|
||||||
|
|
||||||
|
if (reviews == null || !reviews.Any())
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Простое среднее арифметическое
|
||||||
|
double avg = reviews.Average(r => r.Rating);
|
||||||
|
|
||||||
|
// Округлим до 1 знака (опционально)
|
||||||
|
return Math.Round(avg, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.BackgroundJobs
|
||||||
|
{
|
||||||
|
public class AutoReviewJob : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<AutoReviewJob> _logger;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public AutoReviewJob(ILogger<AutoReviewJob> logger, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Запуск AutoReviewJob...");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AutoReviewJob: Проверка заказов без отзывов...");
|
||||||
|
|
||||||
|
// TODO: Реализовать логику авто-отзыва
|
||||||
|
// 1. Получить все завершенные заказы (из модуля Order или через Integration Events/View)
|
||||||
|
// 2. Отфильтровать те, которые завершены > 7 дней назад
|
||||||
|
// 3. Проверить, есть ли уже отзыв для этого заказа (в таблице Reviews)
|
||||||
|
// 4. Если отзыва нет -> создать Review с Rating=5 и IsAutoGenerated=true
|
||||||
|
// 5. Сохранить в БД (ReviewRepository.AddAsync)
|
||||||
|
|
||||||
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AutoReviewJob>>();
|
||||||
|
var reviewRepo = scope.ServiceProvider.GetRequiredService<Nashel.Modules.Reputation.Domain.Repositories.IReviewRepository>();
|
||||||
|
|
||||||
|
// Логика авто-отзыва:
|
||||||
|
// 1. Ищем заказы, которые были завершены ровно 7 дней назад.
|
||||||
|
// Это можно сделать через интеграционное событие или запрос к Order Module.
|
||||||
|
// 2. Для каждого такого заказа проверяем, оставил ли уже клиент отзыв.
|
||||||
|
// 3. Если отзыва нет:
|
||||||
|
// Review autoReview = Review.Create(order.Id, Guid.Empty (System), order.EmployeeId, 5, "Авто-отзыв: Заказ успешно завершен.", isAutoGenerated: true);
|
||||||
|
// await reviewRepo.AddAsync(autoReview);
|
||||||
|
|
||||||
|
logger.LogInformation("AutoReviewJob: Проверка завершена.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждем 24 часа перед следующей проверкой
|
||||||
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ошибка в AutoReviewJob");
|
||||||
|
// Ждем немного перед повторной попыткой при ошибке
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Services;
|
||||||
|
using Nashel.Modules.Reputation.Infrastructure.Persistence;
|
||||||
|
using Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddReputationModule(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||||
|
|
||||||
|
services.AddDbContext<ReputationDbContext>(options =>
|
||||||
|
options.UseNpgsql(connectionString));
|
||||||
|
|
||||||
|
services.AddScoped<IReviewRepository, ReviewRepository>();
|
||||||
|
services.AddScoped<IDisputeRepository, DisputeRepository>();
|
||||||
|
services.AddScoped<RatingCalculator>();
|
||||||
|
|
||||||
|
services.AddMediatR(cfg =>
|
||||||
|
{
|
||||||
|
cfg.RegisterServicesFromAssembly(Assembly.Load("Nashel.Modules.Reputation.Application"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фоновые задачи
|
||||||
|
services.AddHostedService<Nashel.Modules.Reputation.Infrastructure.BackgroundJobs.AutoReviewJob>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nashel.Modules.Reputation.Infrastructure.Persistence;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ReputationDbContext))]
|
||||||
|
[Migration("20260212125841_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("reputation")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("InitiatorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Disputes", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("DisputeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FileUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UploaderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DisputeId");
|
||||||
|
|
||||||
|
b.ToTable("DisputeEvidence", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AuthorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAutoGenerated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("MediaUrls")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Rating")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("TargetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TargetId");
|
||||||
|
|
||||||
|
b.ToTable("Reviews", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null)
|
||||||
|
.WithMany("Evidences")
|
||||||
|
.HasForeignKey("DisputeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Evidences");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "reputation");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Disputes",
|
||||||
|
schema: "reputation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
InitiatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Reason = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Disputes", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Reviews",
|
||||||
|
schema: "reputation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AuthorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TargetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Rating = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Text = table.Column<string>(type: "text", nullable: false),
|
||||||
|
MediaUrls = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
|
IsAutoGenerated = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Reviews", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DisputeEvidence",
|
||||||
|
schema: "reputation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
DisputeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UploaderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileUrl = table.Column<string>(type: "text", nullable: false),
|
||||||
|
FileType = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DisputeEvidence", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DisputeEvidence_Disputes_DisputeId",
|
||||||
|
column: x => x.DisputeId,
|
||||||
|
principalSchema: "reputation",
|
||||||
|
principalTable: "Disputes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DisputeEvidence_DisputeId",
|
||||||
|
schema: "reputation",
|
||||||
|
table: "DisputeEvidence",
|
||||||
|
column: "DisputeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Reviews_TargetId",
|
||||||
|
schema: "reputation",
|
||||||
|
table: "Reviews",
|
||||||
|
column: "TargetId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DisputeEvidence",
|
||||||
|
schema: "reputation");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Reviews",
|
||||||
|
schema: "reputation");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Disputes",
|
||||||
|
schema: "reputation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nashel.Modules.Reputation.Infrastructure.Persistence;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ReputationDbContext))]
|
||||||
|
partial class ReputationDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("reputation")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("InitiatorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Disputes", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("DisputeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FileUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UploaderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DisputeId");
|
||||||
|
|
||||||
|
b.ToTable("DisputeEvidence", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AuthorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAutoGenerated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("MediaUrls")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Rating")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("TargetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TargetId");
|
||||||
|
|
||||||
|
b.ToTable("Reviews", "reputation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null)
|
||||||
|
.WithMany("Evidences")
|
||||||
|
.HasForeignKey("DisputeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Evidences");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.1.25080.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>Nashel.Modules.Reputation.Infrastructure</RootNamespace>
|
<RootNamespace>Nashel.Modules.Reputation.Infrastructure</RootNamespace>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class ReviewConfiguration : IEntityTypeConfiguration<Review>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Review> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(r => r.Id);
|
||||||
|
|
||||||
|
// Индекс для быстрого поиска по TargetId
|
||||||
|
builder.HasIndex(r => r.TargetId);
|
||||||
|
|
||||||
|
// MediaUrls хранится как простой JSON или Primitive Collection
|
||||||
|
// В EF Core 8+ есть поддержка Primitive Collections.
|
||||||
|
// Для Postgres Npgsql это text[] array по умолчанию.
|
||||||
|
// В 10-й версии это должно работать из коробки.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DisputeConfiguration : IEntityTypeConfiguration<Dispute>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Dispute> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(d => d.Id);
|
||||||
|
builder.Property(d => d.Status).HasConversion<string>();
|
||||||
|
|
||||||
|
// Отношение One-to-Many с Evidence
|
||||||
|
builder.HasMany(d => d.Evidences)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey(e => e.DisputeId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
|
public class DisputeRepository : IDisputeRepository
|
||||||
|
{
|
||||||
|
private readonly ReputationDbContext _context;
|
||||||
|
|
||||||
|
public DisputeRepository(ReputationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.Disputes.AddAsync(dispute, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dispute?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Disputes
|
||||||
|
.Include(d => d.Evidences)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_context.Disputes.Update(dispute);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
|
public class ReviewRepository : IReviewRepository
|
||||||
|
{
|
||||||
|
private readonly ReputationDbContext _context;
|
||||||
|
|
||||||
|
public ReviewRepository(ReputationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Review review, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.Reviews.AddAsync(review, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Review?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Reviews.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Review>> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Reviews
|
||||||
|
.Where(r => r.TargetId == targetId)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class ReputationDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Review> Reviews { get; set; }
|
||||||
|
public DbSet<Dispute> Disputes { get; set; }
|
||||||
|
|
||||||
|
public ReputationDbContext(DbContextOptions<ReputationDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("reputation");
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReputationDbContext).Assembly);
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Application\Nashel.Modules.Reputation.Application.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>Nashel.Modules.Reputation.Presentation</RootNamespace>
|
<RootNamespace>Nashel.Modules.Reputation.Presentation</RootNamespace>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Application\Nashel.Modules.Reputation.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR" Version="14.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
using Nashel.Modules.Reputation.Application.Queries;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Presentation;
|
||||||
|
|
||||||
|
public static class ReputationEndpoints
|
||||||
|
{
|
||||||
|
public static void MapReputationEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/reputation").WithTags("Reputation");
|
||||||
|
|
||||||
|
// POST /api/reputation/reviews
|
||||||
|
group.MapPost("/reviews", async ([FromBody] CreateReviewRequest request, ISender sender) =>
|
||||||
|
{
|
||||||
|
var command = new CreateReviewCommand(
|
||||||
|
request.OrderId,
|
||||||
|
request.AuthorId,
|
||||||
|
request.TargetId,
|
||||||
|
request.Rating,
|
||||||
|
request.Text
|
||||||
|
);
|
||||||
|
var reviewId = await sender.Send(command);
|
||||||
|
return Results.Ok(reviewId);
|
||||||
|
})
|
||||||
|
.WithName("CreateReview")
|
||||||
|
.WithOpenApi(operation => new(operation) { Summary = "Создать отзыв", Description = "Оставить отзыв о пользователе по завершенному заказу." });
|
||||||
|
|
||||||
|
// GET /api/reputation/reviews/{userId}
|
||||||
|
group.MapGet("/reviews/{userId:guid}", async (Guid userId, ISender sender) =>
|
||||||
|
{
|
||||||
|
var query = new GetReviewsByTargetQuery(userId);
|
||||||
|
var result = await sender.Send(query);
|
||||||
|
return Results.Ok(result);
|
||||||
|
})
|
||||||
|
.WithName("GetUserReviews")
|
||||||
|
.WithOpenApi(operation => new(operation) { Summary = "Получить отзывы пользователя", Description = "Возвращает список отзывов для конкретного пользователя." });
|
||||||
|
|
||||||
|
// POST /api/reputation/disputes
|
||||||
|
group.MapPost("/disputes", async ([FromBody] OpenDisputeRequest request, ISender sender) =>
|
||||||
|
{
|
||||||
|
var command = new OpenDisputeCommand(request.OrderId, request.InitiatorId, request.Reason);
|
||||||
|
var disputeId = await sender.Send(command);
|
||||||
|
return Results.Ok(disputeId);
|
||||||
|
})
|
||||||
|
.WithName("OpenDispute")
|
||||||
|
.WithOpenApi(operation => new(operation) { Summary = "Открыть спор (арбитраж)", Description = "Начинает процедуру спора по заказу. Блокирует выплаты." });
|
||||||
|
|
||||||
|
// POST /api/reputation/disputes/{id}/evidence
|
||||||
|
group.MapPost("/disputes/{id:guid}/evidence", async (Guid id, [FromBody] AddEvidenceRequest request, ISender sender) =>
|
||||||
|
{
|
||||||
|
var command = new AddEvidenceCommand(id, request.UploaderId, request.FileUrl);
|
||||||
|
await sender.Send(command);
|
||||||
|
return Results.Ok();
|
||||||
|
})
|
||||||
|
.WithName("AddDisputeEvidence")
|
||||||
|
.WithOpenApi(operation => new(operation) { Summary = "Добавить улики к спору", Description = "Добавляет фото/видео доказательства. Доступно только в статусе EvidenceCollection." });
|
||||||
|
|
||||||
|
// POST /api/reputation/disputes/{id}/resolve
|
||||||
|
group.MapPost("/disputes/{id:guid}/resolve", async (Guid id, [FromBody] ResolveDisputeRequest request, ISender sender) =>
|
||||||
|
{
|
||||||
|
var command = new ResolveDisputeCommand(id, request.ResolutionDetails);
|
||||||
|
await sender.Send(command);
|
||||||
|
return Results.Ok();
|
||||||
|
})
|
||||||
|
.WithName("ResolveDispute")
|
||||||
|
.WithOpenApi(operation => new(operation) { Summary = "Разрешить спор (Admin)", Description = "Принимает решение по спору. Доступно только администратору." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос на разрешение спора.
|
||||||
|
/// </summary>
|
||||||
|
public record ResolveDisputeRequest(string ResolutionDetails);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос на создание отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateReviewRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID заказа.
|
||||||
|
/// </summary>
|
||||||
|
public Guid OrderId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID автора отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public Guid AuthorId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID получателя отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public Guid TargetId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оценка (1-5).
|
||||||
|
/// </summary>
|
||||||
|
public int Rating { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Текст отзыва.
|
||||||
|
/// </summary>
|
||||||
|
public string Text { get; init; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос на открытие спора.
|
||||||
|
/// </summary>
|
||||||
|
public record OpenDisputeRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID заказа.
|
||||||
|
/// </summary>
|
||||||
|
public Guid OrderId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID инициатора.
|
||||||
|
/// </summary>
|
||||||
|
public Guid InitiatorId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Причина спора.
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; init; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос на добавление улики.
|
||||||
|
/// </summary>
|
||||||
|
public record AddEvidenceRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID загрузившего.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UploaderId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка на файл.
|
||||||
|
/// </summary>
|
||||||
|
public string FileUrl { get; init; } = default!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using MediatR;
|
||||||
|
using Moq;
|
||||||
|
using Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Tests.Application;
|
||||||
|
|
||||||
|
public class ReviewApplicationTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IReviewRepository> _repositoryMock;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock;
|
||||||
|
private readonly CreateReviewHandler _handler;
|
||||||
|
|
||||||
|
public ReviewApplicationTests()
|
||||||
|
{
|
||||||
|
_repositoryMock = new Mock<IReviewRepository>();
|
||||||
|
_mediatorMock = new Mock<IMediator>();
|
||||||
|
_handler = new CreateReviewHandler(_repositoryMock.Object, _mediatorMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task СозданиеОтзыва_Должно_Сохранить_Отзыв_И_Опубликовать_События()
|
||||||
|
{
|
||||||
|
// 1. Arrange
|
||||||
|
var command = new CreateReviewCommand(
|
||||||
|
OrderId: Guid.NewGuid(),
|
||||||
|
AuthorId: Guid.NewGuid(),
|
||||||
|
TargetId: Guid.NewGuid(),
|
||||||
|
Rating: 5,
|
||||||
|
Text: "Отличный опыт!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Act
|
||||||
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
// 3. Assert
|
||||||
|
result.Should().NotBeEmpty();
|
||||||
|
_repositoryMock.Verify(r => r.AddAsync(It.IsAny<Review>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_mediatorMock.Verify(m => m.Publish(It.IsAny<INotification>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using MediatR;
|
||||||
|
using Moq;
|
||||||
|
using Nashel.Modules.Reputation.Application.Commands;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
using Nashel.Modules.Reputation.Application.Events;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Tests.Application;
|
||||||
|
|
||||||
|
public class OpenDisputeTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IDisputeRepository> _repositoryMock;
|
||||||
|
private readonly Mock<IPublisher> _publisherMock;
|
||||||
|
private readonly OpenDisputeHandler _handler;
|
||||||
|
|
||||||
|
public OpenDisputeTests()
|
||||||
|
{
|
||||||
|
_repositoryMock = new Mock<IDisputeRepository>();
|
||||||
|
_publisherMock = new Mock<IPublisher>();
|
||||||
|
_handler = new OpenDisputeHandler(_repositoryMock.Object, _publisherMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Обработка_Должна_Создать_Спор_И_Опубликовать_Событие()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new OpenDisputeCommand(Guid.NewGuid(), Guid.NewGuid(), "Причина");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _handler.Handle(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeEmpty();
|
||||||
|
_repositoryMock.Verify(r => r.AddAsync(It.IsAny<Dispute>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_publisherMock.Verify(p => p.Publish(It.IsAny<DisputeOpenedIntegrationEvent>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Enums;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Tests.Domain;
|
||||||
|
|
||||||
|
public class DisputeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Открытие_Должно_Установить_Статус_Сбор_Улик()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
dispute.Status.Should().Be(DisputeStatus.EvidenceCollection);
|
||||||
|
dispute.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ДобавлениеУлик_Должно_Провалиться_Если_Статус_Не_СборУлик()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема");
|
||||||
|
|
||||||
|
// Принудительно меняем статус через разрешение спора
|
||||||
|
dispute.Resolve("Решено");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var evidence = DisputeEvidence.Create(dispute.Id, Guid.NewGuid(), "url.com");
|
||||||
|
var action = () => dispute.AddEvidence(evidence);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
action.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Добавление улик возможно только на этапе сбора доказательств.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Events;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Tests.Domain;
|
||||||
|
|
||||||
|
public class ReviewTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Создание_Должно_Установить_Правильные_Свойства()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var orderId = Guid.NewGuid();
|
||||||
|
var authorId = Guid.NewGuid();
|
||||||
|
var targetId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var review = Review.Create(orderId, authorId, targetId, 5, "Отличная работа");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
review.Rating.Should().Be(5);
|
||||||
|
review.Text.Should().Be("Отличная работа");
|
||||||
|
review.IsAutoGenerated.Should().BeFalse();
|
||||||
|
review.DomainEvents.Should().ContainSingle(e => e is ReviewCreatedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(6)]
|
||||||
|
public void Создание_Должно_Выбросить_Исключение_Когда_Рейтинг_Недействителен(int invalidRating)
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var action = () => Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), invalidRating, "текст");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
action.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Обновление_Должно_Выбросить_Исключение_Через_3_Дня()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var review = Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 5, "Текст");
|
||||||
|
|
||||||
|
// Используем рефлексию для изменения даты создания
|
||||||
|
var createdAtField = typeof(Review).GetField("_createdAt", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
createdAtField?.SetValue(review, DateTime.UtcNow.AddDays(-4));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var action = () => review.Update("Новый текст", 4);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
action.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Редактирование отзыва доступно только в течение 3 дней после создания.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Entities;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Repositories;
|
||||||
|
using Nashel.Modules.Reputation.Domain.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nashel.Modules.Reputation.Tests.Services;
|
||||||
|
|
||||||
|
public class RatingCalculatorTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IReviewRepository> _repositoryMock;
|
||||||
|
private readonly RatingCalculator _service;
|
||||||
|
|
||||||
|
public RatingCalculatorTests()
|
||||||
|
{
|
||||||
|
_repositoryMock = new Mock<IReviewRepository>();
|
||||||
|
_service = new RatingCalculator(_repositoryMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task РасчетСреднего_Должен_Вернуть_Верное_Значение()
|
||||||
|
{
|
||||||
|
// 1. Arrange
|
||||||
|
var targetId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var reviews = new List<Review>
|
||||||
|
{
|
||||||
|
Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 5, "Хорошо"),
|
||||||
|
Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 3, "Средне")
|
||||||
|
};
|
||||||
|
// 5 + 3 = 8 / 2 = 4.0
|
||||||
|
|
||||||
|
_repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(reviews);
|
||||||
|
|
||||||
|
// 2. Act
|
||||||
|
var average = await _service.CalculateAverage(targetId);
|
||||||
|
|
||||||
|
// 3. Assert
|
||||||
|
average.Should().Be(4.0, "Средний рейтинг должен рассчитываться правильно.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task РасчетСреднего_Должен_Вернуть_Ноль_Если_Отзывов_Нет()
|
||||||
|
{
|
||||||
|
// 1. Arrange
|
||||||
|
var targetId = Guid.NewGuid();
|
||||||
|
_repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Review>());
|
||||||
|
|
||||||
|
// 2. Act
|
||||||
|
var average = await _service.CalculateAverage(targetId);
|
||||||
|
|
||||||
|
// 3. Assert
|
||||||
|
average.Should().Be(0.0, "Рейтинг должен быть 0, если отзывов нет.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Domain\Nashel.Modules.Reputation.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Application\Nashel.Modules.Reputation.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user