feat: 增加分页排序与FluentValidation

This commit is contained in:
2025-12-02 10:50:43 +08:00
parent 93141fbf0c
commit 97bf6cacb0
63 changed files with 904 additions and 49 deletions

View File

@@ -0,0 +1,35 @@
using FluentValidation;
using MediatR;
namespace TakeoutSaaS.Application.App.Common.Behaviors;
/// <summary>
/// MediatR 请求验证行为,统一触发 FluentValidation。
/// </summary>
/// <typeparam name="TRequest">请求类型。</typeparam>
/// <typeparam name="TResponse">响应类型。</typeparam>
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull, IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators = validators;
/// <summary>
/// 执行验证并在通过时继续后续处理。
/// </summary>
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f is not null).ToList();
if (failures.Count > 0)
{
throw new ValidationException(failures);
}
}
return await next();
}
}

View File

@@ -81,4 +81,9 @@ public sealed class DeliveryOrderDto
/// 事件列表。
/// </summary>
public IReadOnlyList<DeliveryEventDto> Events { get; init; } = Array.Empty<DeliveryEventDto>();
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -56,6 +56,7 @@ public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository delive
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,

View File

@@ -47,6 +47,7 @@ public sealed class GetDeliveryOrderByIdQueryHandler(
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,

View File

@@ -23,7 +23,13 @@ public sealed class SearchDeliveryOrdersQueryHandler(
var tenantId = _tenantProvider.GetCurrentTenantId();
var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken);
return orders.Select(order => new DeliveryOrderDto
var sorted = ApplySorting(orders, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(order => new DeliveryOrderDto
{
Id = order.Id,
TenantId = order.TenantId,
@@ -37,7 +43,21 @@ public sealed class SearchDeliveryOrdersQueryHandler(
DispatchedAt = order.DispatchedAt,
PickedUpAt = order.PickedUpAt,
DeliveredAt = order.DeliveredAt,
FailureReason = order.FailureReason
FailureReason = order.FailureReason,
CreatedAt = order.CreatedAt
}).ToList();
}
private static IOrderedEnumerable<Domain.Deliveries.Entities.DeliveryOrder> ApplySorting(
IReadOnlyCollection<Domain.Deliveries.Entities.DeliveryOrder> orders,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"status" => sortDescending ? orders.OrderByDescending(x => x.Status) : orders.OrderBy(x => x.Status),
"provider" => sortDescending ? orders.OrderByDescending(x => x.Provider) : orders.OrderBy(x => x.Provider),
_ => sortDescending ? orders.OrderByDescending(x => x.CreatedAt) : orders.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -66,6 +66,7 @@ public sealed class UpdateDeliveryOrderCommandHandler(
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,

View File

@@ -18,4 +18,24 @@ public sealed class SearchDeliveryOrdersQuery : IRequest<IReadOnlyList<DeliveryO
/// 配送状态。
/// </summary>
public DeliveryStatus? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段createdAt/status/provider
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Commands;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 创建配送单命令验证器。
/// </summary>
public sealed class CreateDeliveryOrderCommandValidator : AbstractValidator<CreateDeliveryOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateDeliveryOrderCommandValidator()
{
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
RuleFor(x => x.CourierName).MaximumLength(64);
RuleFor(x => x.CourierPhone).MaximumLength(32);
RuleFor(x => x.FailureReason).MaximumLength(256);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Queries;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 配送单列表查询验证器。
/// </summary>
public sealed class SearchDeliveryOrdersQueryValidator : AbstractValidator<SearchDeliveryOrdersQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchDeliveryOrdersQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Commands;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 更新配送单命令验证器。
/// </summary>
public sealed class UpdateDeliveryOrderCommandValidator : AbstractValidator<UpdateDeliveryOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateDeliveryOrderCommandValidator()
{
RuleFor(x => x.DeliveryOrderId).GreaterThan(0);
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
RuleFor(x => x.CourierName).MaximumLength(64);
RuleFor(x => x.CourierPhone).MaximumLength(32);
RuleFor(x => x.FailureReason).MaximumLength(256);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
}
}

View File

@@ -1,6 +1,8 @@
using System.Reflection;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Common.Behaviors;
namespace TakeoutSaaS.Application.App.Extensions;
@@ -17,6 +19,8 @@ public static class AppApplicationServiceCollectionExtensions
public static IServiceCollection AddAppApplication(this IServiceCollection services)
{
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}

View File

@@ -60,4 +60,9 @@ public sealed class MerchantDto
/// 入驻时间。
/// </summary>
public DateTime? JoinedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -49,6 +49,7 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
JoinedAt = merchant.JoinedAt,
CreatedAt = merchant.CreatedAt
};
}

View File

@@ -36,7 +36,8 @@ public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepo
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
JoinedAt = merchant.JoinedAt,
CreatedAt = merchant.CreatedAt
};
}
}

View File

@@ -23,20 +23,44 @@ public sealed class SearchMerchantsQueryHandler(
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchants = await _merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken);
return merchants
.Select(merchant => new MerchantDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
BrandName = merchant.BrandName,
BrandAlias = merchant.BrandAlias,
LogoUrl = merchant.LogoUrl,
Category = merchant.Category,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
})
var sorted = ApplySorting(merchants, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(merchant => new MerchantDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
BrandName = merchant.BrandName,
BrandAlias = merchant.BrandAlias,
LogoUrl = merchant.LogoUrl,
Category = merchant.Category,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt,
CreatedAt = merchant.CreatedAt
}).ToList();
}
private static IOrderedEnumerable<Domain.Merchants.Entities.Merchant> ApplySorting(
IReadOnlyCollection<Domain.Merchants.Entities.Merchant> merchants,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"brandname" => sortDescending
? merchants.OrderByDescending(x => x.BrandName)
: merchants.OrderBy(x => x.BrandName),
"status" => sortDescending
? merchants.OrderByDescending(x => x.Status)
: merchants.OrderBy(x => x.Status),
_ => sortDescending
? merchants.OrderByDescending(x => x.CreatedAt)
: merchants.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -60,6 +60,7 @@ public sealed class UpdateMerchantCommandHandler(
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
JoinedAt = merchant.JoinedAt,
CreatedAt = merchant.CreatedAt
};
}

View File

@@ -13,4 +13,24 @@ public sealed class SearchMerchantsQuery : IRequest<IReadOnlyList<MerchantDto>>
/// 按状态过滤。
/// </summary>
public MerchantStatus? Status { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段brandName/status/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Merchants.Commands;
namespace TakeoutSaaS.Application.App.Merchants.Validators;
/// <summary>
/// 创建商户命令验证器。
/// </summary>
public sealed class CreateMerchantCommandValidator : AbstractValidator<CreateMerchantCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateMerchantCommandValidator()
{
RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128);
RuleFor(x => x.BrandAlias).MaximumLength(64);
RuleFor(x => x.LogoUrl).MaximumLength(256);
RuleFor(x => x.Category).MaximumLength(64);
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Merchants.Queries;
namespace TakeoutSaaS.Application.App.Merchants.Validators;
/// <summary>
/// 商户列表查询验证器。
/// </summary>
public sealed class SearchMerchantsQueryValidator : AbstractValidator<SearchMerchantsQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchMerchantsQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Merchants.Commands;
namespace TakeoutSaaS.Application.App.Merchants.Validators;
/// <summary>
/// 更新商户命令验证器。
/// </summary>
public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMerchantCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateMerchantCommandValidator()
{
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128);
RuleFor(x => x.BrandAlias).MaximumLength(64);
RuleFor(x => x.LogoUrl).MaximumLength(256);
RuleFor(x => x.Category).MaximumLength(64);
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
}
}

View File

@@ -138,4 +138,9 @@ public sealed class OrderDto
/// 退款申请。
/// </summary>
public IReadOnlyList<RefundRequestDto> Refunds { get; init; } = Array.Empty<RefundRequestDto>();
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -151,6 +151,7 @@ public sealed class CreateOrderCommandHandler(
RequestedAt = x.RequestedAt,
ProcessedAt = x.ProcessedAt,
ReviewNotes = x.ReviewNotes
}).ToList()
}).ToList(),
CreatedAt = order.CreatedAt
};
}

View File

@@ -97,6 +97,7 @@ public sealed class GetOrderByIdQueryHandler(
RequestedAt = x.RequestedAt,
ProcessedAt = x.ProcessedAt,
ReviewNotes = x.ReviewNotes
}).ToList()
}).ToList(),
CreatedAt = order.CreatedAt
};
}

View File

@@ -36,7 +36,13 @@ public sealed class SearchOrdersQueryHandler(
.ToList();
}
return orders.Select(order => new OrderDto
var sorted = ApplySorting(orders, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(order => new OrderDto
{
Id = order.Id,
TenantId = order.TenantId,
@@ -59,7 +65,22 @@ public sealed class SearchOrdersQueryHandler(
FinishedAt = order.FinishedAt,
CancelledAt = order.CancelledAt,
CancelReason = order.CancelReason,
Remark = order.Remark
Remark = order.Remark,
CreatedAt = order.CreatedAt
}).ToList();
}
private static IOrderedEnumerable<Domain.Orders.Entities.Order> ApplySorting(
IReadOnlyCollection<Domain.Orders.Entities.Order> orders,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"paidat" => sortDescending ? orders.OrderByDescending(x => x.PaidAt) : orders.OrderBy(x => x.PaidAt),
"status" => sortDescending ? orders.OrderByDescending(x => x.Status) : orders.OrderBy(x => x.Status),
"payableamount" => sortDescending ? orders.OrderByDescending(x => x.PayableAmount) : orders.OrderBy(x => x.PayableAmount),
_ => sortDescending ? orders.OrderByDescending(x => x.CreatedAt) : orders.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -129,6 +129,7 @@ public sealed class UpdateOrderCommandHandler(
RequestedAt = x.RequestedAt,
ProcessedAt = x.ProcessedAt,
ReviewNotes = x.ReviewNotes
}).ToList()
}).ToList(),
CreatedAt = order.CreatedAt
};
}

View File

@@ -29,4 +29,24 @@ public sealed class SearchOrdersQuery : IRequest<IReadOnlyList<OrderDto>>
/// 订单号(模糊或精确,由调用方控制)。
/// </summary>
public string? OrderNo { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段createdAt/paidAt/status/payableAmount
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,46 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Orders.Commands;
namespace TakeoutSaaS.Application.App.Orders.Validators;
/// <summary>
/// 创建订单命令验证器。
/// </summary>
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateOrderCommandValidator()
{
RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32);
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CustomerPhone).MaximumLength(32);
RuleFor(x => x.CustomerName).MaximumLength(64);
RuleFor(x => x.TableNo).MaximumLength(32);
RuleFor(x => x.QueueNumber).MaximumLength(32);
RuleFor(x => x.CancelReason).MaximumLength(256);
RuleFor(x => x.Remark).MaximumLength(512);
RuleFor(x => x.ItemsAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.PayableAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("订单明细不能为空");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.ProductName).NotEmpty().MaximumLength(128);
item.RuleFor(i => i.SkuName).MaximumLength(128);
item.RuleFor(i => i.Unit).MaximumLength(16);
item.RuleFor(i => i.Quantity).GreaterThan(0);
item.RuleFor(i => i.UnitPrice).GreaterThanOrEqualTo(0);
item.RuleFor(i => i.DiscountAmount).GreaterThanOrEqualTo(0);
item.RuleFor(i => i.SubTotal).GreaterThanOrEqualTo(0);
item.RuleFor(i => i.AttributesJson).MaximumLength(4000);
});
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Orders.Queries;
namespace TakeoutSaaS.Application.App.Orders.Validators;
/// <summary>
/// 订单列表查询验证器。
/// </summary>
public sealed class SearchOrdersQueryValidator : AbstractValidator<SearchOrdersQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchOrdersQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
RuleFor(x => x.OrderNo).MaximumLength(32);
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Orders.Commands;
namespace TakeoutSaaS.Application.App.Orders.Validators;
/// <summary>
/// 更新订单命令验证器。
/// </summary>
public sealed class UpdateOrderCommandValidator : AbstractValidator<UpdateOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateOrderCommandValidator()
{
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32);
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CustomerPhone).MaximumLength(32);
RuleFor(x => x.CustomerName).MaximumLength(64);
RuleFor(x => x.TableNo).MaximumLength(32);
RuleFor(x => x.QueueNumber).MaximumLength(32);
RuleFor(x => x.CancelReason).MaximumLength(256);
RuleFor(x => x.Remark).MaximumLength(512);
RuleFor(x => x.ItemsAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.PayableAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0);
}
}

View File

@@ -71,4 +71,9 @@ public sealed class PaymentDto
/// 退款记录。
/// </summary>
public IReadOnlyList<PaymentRefundDto> Refunds { get; init; } = Array.Empty<PaymentRefundDto>();
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -52,6 +52,7 @@ public sealed class CreatePaymentCommandHandler(IPaymentRepository paymentReposi
PaidAt = payment.PaidAt,
Remark = payment.Remark,
Payload = payment.Payload,
CreatedAt = payment.CreatedAt,
Refunds = refunds.Select(x => new PaymentRefundDto
{
Id = x.Id,

View File

@@ -45,6 +45,7 @@ public sealed class GetPaymentByIdQueryHandler(
PaidAt = payment.PaidAt,
Remark = payment.Remark,
Payload = payment.Payload,
CreatedAt = payment.CreatedAt,
Refunds = refunds.Select(x => new PaymentRefundDto
{
Id = x.Id,

View File

@@ -1,6 +1,7 @@
using MediatR;
using TakeoutSaaS.Application.App.Payments.Dto;
using TakeoutSaaS.Application.App.Payments.Queries;
using TakeoutSaaS.Domain.Payments.Entities;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
@@ -28,7 +29,13 @@ public sealed class SearchPaymentsQueryHandler(
payments = payments.Where(x => x.OrderId == request.OrderId.Value).ToList();
}
return payments.Select(payment => new PaymentDto
var sorted = ApplySorting(payments, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(payment => new PaymentDto
{
Id = payment.Id,
TenantId = payment.TenantId,
@@ -40,7 +47,22 @@ public sealed class SearchPaymentsQueryHandler(
ChannelTransactionId = payment.ChannelTransactionId,
PaidAt = payment.PaidAt,
Remark = payment.Remark,
Payload = payment.Payload
Payload = payment.Payload,
CreatedAt = payment.CreatedAt
}).ToList();
}
private static IOrderedEnumerable<Domain.Payments.Entities.PaymentRecord> ApplySorting(
IReadOnlyCollection<Domain.Payments.Entities.PaymentRecord> payments,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"paidat" => sortDescending ? payments.OrderByDescending(x => x.PaidAt) : payments.OrderBy(x => x.PaidAt),
"status" => sortDescending ? payments.OrderByDescending(x => x.Status) : payments.OrderBy(x => x.Status),
"amount" => sortDescending ? payments.OrderByDescending(x => x.Amount) : payments.OrderBy(x => x.Amount),
_ => sortDescending ? payments.OrderByDescending(x => x.CreatedAt) : payments.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -62,6 +62,7 @@ public sealed class UpdatePaymentCommandHandler(
PaidAt = payment.PaidAt,
Remark = payment.Remark,
Payload = payment.Payload,
CreatedAt = payment.CreatedAt,
Refunds = refunds.Select(x => new PaymentRefundDto
{
Id = x.Id,

View File

@@ -18,4 +18,24 @@ public sealed class SearchPaymentsQuery : IRequest<IReadOnlyList<PaymentDto>>
/// 支付状态。
/// </summary>
public PaymentStatus? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段createdAt/paidAt/status/amount
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Payments.Commands;
namespace TakeoutSaaS.Application.App.Payments.Validators;
/// <summary>
/// 创建支付记录命令验证器。
/// </summary>
public sealed class CreatePaymentCommandValidator : AbstractValidator<CreatePaymentCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreatePaymentCommandValidator()
{
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.Amount).GreaterThan(0);
RuleFor(x => x.TradeNo).MaximumLength(64);
RuleFor(x => x.ChannelTransactionId).MaximumLength(64);
RuleFor(x => x.Remark).MaximumLength(256);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Payments.Queries;
namespace TakeoutSaaS.Application.App.Payments.Validators;
/// <summary>
/// 支付记录查询验证器。
/// </summary>
public sealed class SearchPaymentsQueryValidator : AbstractValidator<SearchPaymentsQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchPaymentsQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Payments.Commands;
namespace TakeoutSaaS.Application.App.Payments.Validators;
/// <summary>
/// 更新支付记录命令验证器。
/// </summary>
public sealed class UpdatePaymentCommandValidator : AbstractValidator<UpdatePaymentCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdatePaymentCommandValidator()
{
RuleFor(x => x.PaymentId).GreaterThan(0);
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.Amount).GreaterThan(0);
RuleFor(x => x.TradeNo).MaximumLength(64);
RuleFor(x => x.ChannelTransactionId).MaximumLength(64);
RuleFor(x => x.Remark).MaximumLength(256);
}
}

View File

@@ -112,4 +112,9 @@ public sealed class ProductDto
/// 是否推荐。
/// </summary>
public bool IsFeatured { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -72,6 +72,7 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
IsFeatured = product.IsFeatured,
CreatedAt = product.CreatedAt
};
}

View File

@@ -47,6 +47,7 @@ public sealed class GetProductByIdQueryHandler(
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
IsFeatured = product.IsFeatured,
CreatedAt = product.CreatedAt
};
}

View File

@@ -28,7 +28,27 @@ public sealed class SearchProductsQueryHandler(
products = products.Where(x => x.StoreId == request.StoreId.Value).ToList();
}
return products.Select(MapToDto).ToList();
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(MapToDto).ToList();
}
private static IOrderedEnumerable<Domain.Products.Entities.Product> ApplySorting(
IReadOnlyCollection<Domain.Products.Entities.Product> products,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"name" => sortDescending ? products.OrderByDescending(x => x.Name) : products.OrderBy(x => x.Name),
"price" => sortDescending ? products.OrderByDescending(x => x.Price) : products.OrderBy(x => x.Price),
"status" => sortDescending ? products.OrderByDescending(x => x.Status) : products.OrderBy(x => x.Status),
_ => sortDescending ? products.OrderByDescending(x => x.CreatedAt) : products.OrderBy(x => x.CreatedAt)
};
}
private static ProductDto MapToDto(Domain.Products.Entities.Product product) => new()
@@ -52,6 +72,7 @@ public sealed class SearchProductsQueryHandler(
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
IsFeatured = product.IsFeatured,
CreatedAt = product.CreatedAt
};
}

View File

@@ -82,6 +82,7 @@ public sealed class UpdateProductCommandHandler(
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
IsFeatured = product.IsFeatured,
CreatedAt = product.CreatedAt
};
}

View File

@@ -23,4 +23,24 @@ public sealed class SearchProductsQuery : IRequest<IReadOnlyList<ProductDto>>
/// 状态过滤。
/// </summary>
public ProductStatus? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段name/price/status/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 创建商品命令验证器。
/// </summary>
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateProductCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.SpuCode).NotEmpty().MaximumLength(32);
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Subtitle).MaximumLength(256);
RuleFor(x => x.Unit).MaximumLength(16);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Queries;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 商品列表查询验证器。
/// </summary>
public sealed class SearchProductsQueryValidator : AbstractValidator<SearchProductsQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchProductsQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 更新商品命令验证器。
/// </summary>
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateProductCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.SpuCode).NotEmpty().MaximumLength(32);
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Subtitle).MaximumLength(256);
RuleFor(x => x.Unit).MaximumLength(16);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}
}

View File

@@ -111,4 +111,9 @@ public sealed class StoreDto
/// 支持配送。
/// </summary>
public bool SupportsDelivery { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -72,6 +72,7 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository,
DeliveryRadiusKm = store.DeliveryRadiusKm,
SupportsDineIn = store.SupportsDineIn,
SupportsPickup = store.SupportsPickup,
SupportsDelivery = store.SupportsDelivery
SupportsDelivery = store.SupportsDelivery,
CreatedAt = store.CreatedAt
};
}

View File

@@ -47,6 +47,7 @@ public sealed class GetStoreByIdQueryHandler(
DeliveryRadiusKm = store.DeliveryRadiusKm,
SupportsDineIn = store.SupportsDineIn,
SupportsPickup = store.SupportsPickup,
SupportsDelivery = store.SupportsDelivery
SupportsDelivery = store.SupportsDelivery,
CreatedAt = store.CreatedAt
};
}

View File

@@ -28,9 +28,27 @@ public sealed class SearchStoresQueryHandler(
stores = stores.Where(x => x.MerchantId == request.MerchantId.Value).ToList();
}
return stores
.Select(MapToDto)
var sorted = ApplySorting(stores, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return paged.Select(MapToDto).ToList();
}
private static IOrderedEnumerable<Domain.Stores.Entities.Store> ApplySorting(
IReadOnlyCollection<Domain.Stores.Entities.Store> stores,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"name" => sortDescending ? stores.OrderByDescending(x => x.Name) : stores.OrderBy(x => x.Name),
"code" => sortDescending ? stores.OrderByDescending(x => x.Code) : stores.OrderBy(x => x.Code),
"status" => sortDescending ? stores.OrderByDescending(x => x.Status) : stores.OrderBy(x => x.Status),
_ => sortDescending ? stores.OrderByDescending(x => x.CreatedAt) : stores.OrderBy(x => x.CreatedAt)
};
}
private static StoreDto MapToDto(Domain.Stores.Entities.Store store) => new()
@@ -54,6 +72,7 @@ public sealed class SearchStoresQueryHandler(
DeliveryRadiusKm = store.DeliveryRadiusKm,
SupportsDineIn = store.SupportsDineIn,
SupportsPickup = store.SupportsPickup,
SupportsDelivery = store.SupportsDelivery
SupportsDelivery = store.SupportsDelivery,
CreatedAt = store.CreatedAt
};
}

View File

@@ -82,6 +82,7 @@ public sealed class UpdateStoreCommandHandler(
DeliveryRadiusKm = store.DeliveryRadiusKm,
SupportsDineIn = store.SupportsDineIn,
SupportsPickup = store.SupportsPickup,
SupportsDelivery = store.SupportsDelivery
SupportsDelivery = store.SupportsDelivery,
CreatedAt = store.CreatedAt
};
}

View File

@@ -18,4 +18,24 @@ public sealed class SearchStoresQuery : IRequest<IReadOnlyList<StoreDto>>
/// 状态过滤。
/// </summary>
public StoreStatus? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段name/code/status/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 创建门店命令验证器。
/// </summary>
public sealed class CreateStoreCommandValidator : AbstractValidator<CreateStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateStoreCommandValidator()
{
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.Code).NotEmpty().MaximumLength(32);
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Phone).MaximumLength(32);
RuleFor(x => x.ManagerName).MaximumLength(64);
RuleFor(x => x.Province).MaximumLength(64);
RuleFor(x => x.City).MaximumLength(64);
RuleFor(x => x.District).MaximumLength(64);
RuleFor(x => x.Address).MaximumLength(256);
RuleFor(x => x.Announcement).MaximumLength(512);
RuleFor(x => x.Tags).MaximumLength(256);
RuleFor(x => x.DeliveryRadiusKm).GreaterThanOrEqualTo(0);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Queries;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 门店列表查询验证器。
/// </summary>
public sealed class SearchStoresQueryValidator : AbstractValidator<SearchStoresQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchStoresQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新门店命令验证器。
/// </summary>
public sealed class UpdateStoreCommandValidator : AbstractValidator<UpdateStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateStoreCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.Code).NotEmpty().MaximumLength(32);
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Phone).MaximumLength(32);
RuleFor(x => x.ManagerName).MaximumLength(64);
RuleFor(x => x.Province).MaximumLength(64);
RuleFor(x => x.City).MaximumLength(64);
RuleFor(x => x.District).MaximumLength(64);
RuleFor(x => x.Address).MaximumLength(256);
RuleFor(x => x.Announcement).MaximumLength(512);
RuleFor(x => x.Tags).MaximumLength(256);
RuleFor(x => x.DeliveryRadiusKm).GreaterThanOrEqualTo(0);
}
}

View File

@@ -9,6 +9,7 @@
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />