Модуль репутации

This commit is contained in:
Халимов Рустам
2026-02-12 16:11:14 +03:00
parent d6c1f63842
commit 46f008aefa
38 changed files with 1959 additions and 4 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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();

View 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
}
}
}

View File

@@ -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)
{
}
}
}

View 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
}
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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.
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace Nashel.Modules.Reputation.Application.Events;
/// <summary>
/// Интеграционное событие: Спор открыт.
/// Используется для уведомления других модулей (например, модуля Order для блокировки оплаты).
/// </summary>
public record DisputeOpenedIntegrationEvent(Guid DisputeId, Guid OrderId) : INotification;

View File

@@ -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);
// Здесь можно обновить кэш или таблицу профилей пользователей с их текущим рейтингом.
}
}

View File

@@ -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();
}
}

View 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)
}
}

View 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);
}
}

View 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 дней после создания.");
}
}
}

View 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
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
}
}
}
}

View 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;
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View 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!;
}

View File

@@ -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);
}
}

View 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);
}
}

View 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("Добавление улик возможно только на этапе сбора доказательств.");
}
}

View 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 дней после создания.");
}
}

View File

@@ -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, если отзывов нет.");
}
}

View 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>