feat: 管理端核心实体CRUD补齐

This commit is contained in:
2025-12-02 10:19:35 +08:00
parent 1a01454266
commit 93141fbf0c
75 changed files with 4513 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 创建商品命令。
/// </summary>
public sealed class CreateProductCommand : IRequest<ProductDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; set; }
/// <summary>
/// 商品编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 副标题。
/// </summary>
public string? Subtitle { get; set; }
/// <summary>
/// 单位。
/// </summary>
public string? Unit { get; set; }
/// <summary>
/// 现价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 库存数量。
/// </summary>
public int? StockQuantity { get; set; }
/// <summary>
/// 每单限购。
/// </summary>
public int? MaxQuantityPerOrder { get; set; }
/// <summary>
/// 状态。
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Draft;
/// <summary>
/// 主图。
/// </summary>
public string? CoverImage { get; set; }
/// <summary>
/// 图集。
/// </summary>
public string? GalleryImages { get; set; }
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 支持堂食。
/// </summary>
public bool EnableDineIn { get; set; } = true;
/// <summary>
/// 支持自提。
/// </summary>
public bool EnablePickup { get; set; } = true;
/// <summary>
/// 支持配送。
/// </summary>
public bool EnableDelivery { get; set; } = true;
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsFeatured { get; set; }
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 删除商品命令。
/// </summary>
public sealed class DeleteProductCommand : IRequest<bool>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; set; }
}

View File

@@ -0,0 +1,106 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 更新商品命令。
/// </summary>
public sealed class UpdateProductCommand : IRequest<ProductDto?>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; set; }
/// <summary>
/// 商品编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 副标题。
/// </summary>
public string? Subtitle { get; set; }
/// <summary>
/// 单位。
/// </summary>
public string? Unit { get; set; }
/// <summary>
/// 现价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 库存数量。
/// </summary>
public int? StockQuantity { get; set; }
/// <summary>
/// 每单限购。
/// </summary>
public int? MaxQuantityPerOrder { get; set; }
/// <summary>
/// 状态。
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Draft;
/// <summary>
/// 主图。
/// </summary>
public string? CoverImage { get; set; }
/// <summary>
/// 图集。
/// </summary>
public string? GalleryImages { get; set; }
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 支持堂食。
/// </summary>
public bool EnableDineIn { get; set; } = true;
/// <summary>
/// 支持自提。
/// </summary>
public bool EnablePickup { get; set; } = true;
/// <summary>
/// 支持配送。
/// </summary>
public bool EnableDelivery { get; set; } = true;
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsFeatured { get; set; }
}

View File

@@ -0,0 +1,115 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 商品 DTO。
/// </summary>
public sealed class ProductDto
{
/// <summary>
/// 商品 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long CategoryId { get; init; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; init; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 副标题。
/// </summary>
public string? Subtitle { get; init; }
/// <summary>
/// 单位。
/// </summary>
public string? Unit { get; init; }
/// <summary>
/// 现价。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 库存数量。
/// </summary>
public int? StockQuantity { get; init; }
/// <summary>
/// 每单限购。
/// </summary>
public int? MaxQuantityPerOrder { get; init; }
/// <summary>
/// 状态。
/// </summary>
public ProductStatus Status { get; init; }
/// <summary>
/// 主图。
/// </summary>
public string? CoverImage { get; init; }
/// <summary>
/// 图集。
/// </summary>
public string? GalleryImages { get; init; }
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 支持堂食。
/// </summary>
public bool EnableDineIn { get; init; }
/// <summary>
/// 支持自提。
/// </summary>
public bool EnablePickup { get; init; }
/// <summary>
/// 支持配送。
/// </summary>
public bool EnableDelivery { get; init; }
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsFeatured { get; init; }
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 创建商品命令处理器。
/// </summary>
public sealed class CreateProductCommandHandler(IProductRepository productRepository, ILogger<CreateProductCommandHandler> logger)
: IRequestHandler<CreateProductCommand, ProductDto>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ILogger<CreateProductCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
// 1. 构建实体
var product = new Product
{
StoreId = request.StoreId,
CategoryId = request.CategoryId,
SpuCode = request.SpuCode.Trim(),
Name = request.Name.Trim(),
Subtitle = request.Subtitle?.Trim(),
Unit = request.Unit?.Trim(),
Price = request.Price,
OriginalPrice = request.OriginalPrice,
StockQuantity = request.StockQuantity,
MaxQuantityPerOrder = request.MaxQuantityPerOrder,
Status = request.Status,
CoverImage = request.CoverImage?.Trim(),
GalleryImages = request.GalleryImages?.Trim(),
Description = request.Description?.Trim(),
EnableDineIn = request.EnableDineIn,
EnablePickup = request.EnablePickup,
EnableDelivery = request.EnableDelivery,
IsFeatured = request.IsFeatured
};
// 2. 持久化
await _productRepository.AddProductAsync(product, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("创建商品 {ProductId} - {ProductName}", product.Id, product.Name);
// 3. 返回 DTO
return MapToDto(product);
}
private static ProductDto MapToDto(Product product) => new()
{
Id = product.Id,
TenantId = product.TenantId,
StoreId = product.StoreId,
CategoryId = product.CategoryId,
SpuCode = product.SpuCode,
Name = product.Name,
Subtitle = product.Subtitle,
Unit = product.Unit,
Price = product.Price,
OriginalPrice = product.OriginalPrice,
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
};
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 删除商品命令处理器。
/// </summary>
public sealed class DeleteProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<DeleteProductCommandHandler> logger)
: IRequestHandler<DeleteProductCommand, bool>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ILogger<DeleteProductCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<bool> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
{
// 1. 校验存在性
var tenantId = _tenantProvider.GetCurrentTenantId();
var existing = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (existing == null)
{
return false;
}
// 2. 删除
await _productRepository.DeleteProductAsync(request.ProductId, tenantId, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("删除商品 {ProductId}", request.ProductId);
return true;
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 商品详情查询处理器。
/// </summary>
public sealed class GetProductByIdQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<ProductDto?> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
return product == null ? null : MapToDto(product);
}
private static ProductDto MapToDto(Product product) => new()
{
Id = product.Id,
TenantId = product.TenantId,
StoreId = product.StoreId,
CategoryId = product.CategoryId,
SpuCode = product.SpuCode,
Name = product.Name,
Subtitle = product.Subtitle,
Unit = product.Unit,
Price = product.Price,
OriginalPrice = product.OriginalPrice,
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
};
}

View File

@@ -0,0 +1,57 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 商品列表查询处理器。
/// </summary>
public sealed class SearchProductsQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchProductsQuery, IReadOnlyList<ProductDto>>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<IReadOnlyList<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken);
if (request.StoreId.HasValue)
{
products = products.Where(x => x.StoreId == request.StoreId.Value).ToList();
}
return products.Select(MapToDto).ToList();
}
private static ProductDto MapToDto(Domain.Products.Entities.Product product) => new()
{
Id = product.Id,
TenantId = product.TenantId,
StoreId = product.StoreId,
CategoryId = product.CategoryId,
SpuCode = product.SpuCode,
Name = product.Name,
Subtitle = product.Subtitle,
Unit = product.Unit,
Price = product.Price,
OriginalPrice = product.OriginalPrice,
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
};
}

View File

@@ -0,0 +1,87 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 更新商品命令处理器。
/// </summary>
public sealed class UpdateProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<UpdateProductCommandHandler> logger)
: IRequestHandler<UpdateProductCommand, ProductDto?>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ILogger<UpdateProductCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<ProductDto?> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
{
// 1. 读取商品
var tenantId = _tenantProvider.GetCurrentTenantId();
var existing = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (existing == null)
{
return null;
}
// 2. 更新字段
existing.StoreId = request.StoreId;
existing.CategoryId = request.CategoryId;
existing.SpuCode = request.SpuCode.Trim();
existing.Name = request.Name.Trim();
existing.Subtitle = request.Subtitle?.Trim();
existing.Unit = request.Unit?.Trim();
existing.Price = request.Price;
existing.OriginalPrice = request.OriginalPrice;
existing.StockQuantity = request.StockQuantity;
existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
existing.Status = request.Status;
existing.CoverImage = request.CoverImage?.Trim();
existing.GalleryImages = request.GalleryImages?.Trim();
existing.Description = request.Description?.Trim();
existing.EnableDineIn = request.EnableDineIn;
existing.EnablePickup = request.EnablePickup;
existing.EnableDelivery = request.EnableDelivery;
existing.IsFeatured = request.IsFeatured;
// 3. 持久化
await _productRepository.UpdateProductAsync(existing, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("更新商品 {ProductId} - {ProductName}", existing.Id, existing.Name);
// 4. 返回 DTO
return MapToDto(existing);
}
private static ProductDto MapToDto(Product product) => new()
{
Id = product.Id,
TenantId = product.TenantId,
StoreId = product.StoreId,
CategoryId = product.CategoryId,
SpuCode = product.SpuCode,
Name = product.Name,
Subtitle = product.Subtitle,
Unit = product.Unit,
Price = product.Price,
OriginalPrice = product.OriginalPrice,
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
IsFeatured = product.IsFeatured
};
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 获取商品详情查询。
/// </summary>
public sealed class GetProductByIdQuery : IRequest<ProductDto?>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 商品列表查询。
/// </summary>
public sealed class SearchProductsQuery : IRequest<IReadOnlyList<ProductDto>>
{
/// <summary>
/// 门店 ID可选
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 分类 ID可选
/// </summary>
public long? CategoryId { get; init; }
/// <summary>
/// 状态过滤。
/// </summary>
public ProductStatus? Status { get; init; }
}