Модуль репутации
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -154,3 +154,7 @@ src/Modules/Order/Tests/obj/
|
||||
src/Modules/Collaboration/Tests/bin/
|
||||
|
||||
src/Modules/Collaboration/Tests/obj/
|
||||
|
||||
src/Modules/Reputation/Tests/obj/
|
||||
|
||||
src/Modules/Reputation/Tests/bin/
|
||||
|
||||
@@ -12,6 +12,8 @@ using Nashel.Modules.Order.Infrastructure;
|
||||
using Nashel.Modules.Order.Presentation;
|
||||
using Nashel.Modules.Collaboration.Infrastructure;
|
||||
using Nashel.Modules.Collaboration.Presentation;
|
||||
using Nashel.Modules.Reputation.Infrastructure;
|
||||
using Nashel.Modules.Reputation.Presentation;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -62,10 +64,15 @@ builder.Services.AddCatalogModule(builder.Configuration);
|
||||
// Регистрация модуля Geo
|
||||
builder.Services.AddGeoModule(builder.Configuration);
|
||||
|
||||
// Регистрация модуля Order
|
||||
// Регистрация модуля Collaboartion
|
||||
builder.Services.AddCollaborationModule(builder.Configuration);
|
||||
|
||||
// Регистрация модуля Order
|
||||
builder.Services.AddOrderModule(builder.Configuration);
|
||||
|
||||
// Регистрация модуля Reputation
|
||||
builder.Services.AddReputationModule(builder.Configuration);
|
||||
|
||||
// Настройка аутентификации
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
@@ -106,5 +113,6 @@ app.MapCatalogEndpoints();
|
||||
app.MapGeoEndpoints();
|
||||
app.MapOrderEndpoints();
|
||||
app.MapCollaborationEndpoints();
|
||||
app.MapReputationEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
61
src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs
generated
Normal file
61
src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs
generated
Normal file
@@ -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
src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs
generated
Normal file
167
src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs
generated
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
127
src/Modules/Reputation/Domain/Entities/Dispute.cs
Normal file
127
src/Modules/Reputation/Domain/Entities/Dispute.cs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
58
src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs
Normal file
58
src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
118
src/Modules/Reputation/Domain/Entities/Review.cs
Normal file
118
src/Modules/Reputation/Domain/Entities/Review.cs
Normal file
@@ -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 дней после создания.");
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Modules/Reputation/Domain/Enums/DisputeStatus.cs
Normal file
27
src/Modules/Reputation/Domain/Enums/DisputeStatus.cs
Normal file
@@ -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
|
||||
}
|
||||
20
src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs
Normal file
20
src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
23
src/Modules/Reputation/Domain/Repositories/IRepositories.cs
Normal file
23
src/Modules/Reputation/Domain/Repositories/IRepositories.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
40
src/Modules/Reputation/Domain/Services/RatingCalculator.cs
Normal file
40
src/Modules/Reputation/Domain/Services/RatingCalculator.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Modules/Reputation/Infrastructure/DependencyInjection.cs
Normal file
36
src/Modules/Reputation/Infrastructure/DependencyInjection.cs
Normal file
@@ -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
src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs
generated
Normal file
134
src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs
generated
Normal file
@@ -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>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.1.25080.5" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<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">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\Nashel.Modules.Reputation.Application.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Nashel.Modules.Reputation.Presentation</RootNamespace>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</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>
|
||||
146
src/Modules/Reputation/Presentation/ReputationEndpoints.cs
Normal file
146
src/Modules/Reputation/Presentation/ReputationEndpoints.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
39
src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs
Normal file
39
src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
38
src/Modules/Reputation/Tests/Domain/DisputeTests.cs
Normal file
38
src/Modules/Reputation/Tests/Domain/DisputeTests.cs
Normal file
@@ -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("Добавление улик возможно только на этапе сбора доказательств.");
|
||||
}
|
||||
}
|
||||
57
src/Modules/Reputation/Tests/Domain/ReviewTests.cs
Normal file
57
src/Modules/Reputation/Tests/Domain/ReviewTests.cs
Normal file
@@ -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, если отзывов нет.");
|
||||
}
|
||||
}
|
||||
28
src/Modules/Reputation/Tests/Tests.csproj
Normal file
28
src/Modules/Reputation/Tests/Tests.csproj
Normal file
@@ -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