feat: 商品上架/下架与全量详情支持
This commit is contained in:
@@ -116,4 +116,59 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品全量详情。
|
||||
/// </summary>
|
||||
[HttpGet("{productId:long}/detail")]
|
||||
[PermissionAuthorize("product:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDetailDto>> FullDetail(long productId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<ProductDetailDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上架商品。
|
||||
/// </summary>
|
||||
[HttpPost("{productId:long}/publish")]
|
||||
[PermissionAuthorize("product:publish")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> Publish(long productId, [FromBody] PublishProductCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下架商品。
|
||||
/// </summary>
|
||||
[HttpPost("{productId:long}/unpublish")]
|
||||
[PermissionAuthorize("product:publish")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> Unpublish(long productId, [FromBody] UnpublishProductCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"product:read",
|
||||
"product:update",
|
||||
"product:delete",
|
||||
"product:publish",
|
||||
"order:create",
|
||||
"order:read",
|
||||
"order:update",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 上架商品命令。
|
||||
/// </summary>
|
||||
public sealed record PublishProductCommand : IRequest<ProductDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 上架备注。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 下架商品命令。
|
||||
/// </summary>
|
||||
public sealed record UnpublishProductCommand : IRequest<ProductDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下架原因。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组 DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductAddonGroupDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 组 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最小选择数。
|
||||
/// </summary>
|
||||
public int MinSelect { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大选择数。
|
||||
/// </summary>
|
||||
public int MaxSelect { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加料选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductAddonOptionDto> Options { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 加料选项 DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductAddonOptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 选项 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属加料组 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long AddonGroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 额外价格。
|
||||
/// </summary>
|
||||
public decimal? ExtraPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 规格组 DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductAttributeGroupDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 组 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 选择类型。
|
||||
/// </summary>
|
||||
public int SelectionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductAttributeOptionDto> Options { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 规格选项 DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductAttributeOptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 选项 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格组 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long AttributeGroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商品全量详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// SPU 基础信息。
|
||||
/// </summary>
|
||||
public ProductDto Product { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// SKU 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductSkuDto> Skus { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 规格组与选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductAttributeGroupDto> AttributeGroups { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 加料组与选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductAddonGroupDto> AddonGroups { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 价格策略。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductPricingRuleDto> PricingRules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 媒资列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProductMediaAssetDto> MediaAssets { get; init; } = [];
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 DTO。
|
||||
/// 商品 DTO(含 SPU 基础信息)。
|
||||
/// </summary>
|
||||
public sealed class ProductDto
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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 record ProductMediaAssetDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 媒资 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
public MediaAssetType MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL。
|
||||
/// </summary>
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文案。
|
||||
/// </summary>
|
||||
public string? Caption { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 record ProductPricingRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 策略 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 策略类型。
|
||||
/// </summary>
|
||||
public PricingRuleType RuleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 条件 JSON。
|
||||
/// </summary>
|
||||
public string ConditionsJson { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 星期规则。
|
||||
/// </summary>
|
||||
public string? WeekdaysJson { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// SKU DTO。
|
||||
/// </summary>
|
||||
public sealed record ProductSkuDto
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
public string SkuCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 条形码。
|
||||
/// </summary>
|
||||
public string? Barcode { 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 decimal? Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格属性 JSON。
|
||||
/// </summary>
|
||||
public string AttributesJson { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -23,31 +23,6 @@ public sealed class GetProductByIdQueryHandler(
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
|
||||
return product == null ? null : MapToDto(product);
|
||||
return product == null ? null : ProductMapping.ToDto(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,
|
||||
CreatedAt = product.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 GetProductDetailQueryHandler(
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetProductDetailQuery, ProductDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ProductDetailDto?> Handle(GetProductDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取 SPU
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
|
||||
if (product is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 查询子项
|
||||
var skusTask = productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken);
|
||||
var attrGroupsTask = productRepository.GetAttributeGroupsAsync(product.Id, tenantId, cancellationToken);
|
||||
var attrOptionsTask = productRepository.GetAttributeOptionsAsync(product.Id, tenantId, cancellationToken);
|
||||
var addonGroupsTask = productRepository.GetAddonGroupsAsync(product.Id, tenantId, cancellationToken);
|
||||
var addonOptionsTask = productRepository.GetAddonOptionsAsync(product.Id, tenantId, cancellationToken);
|
||||
var mediaTask = productRepository.GetMediaAssetsAsync(product.Id, tenantId, cancellationToken);
|
||||
var pricingTask = productRepository.GetPricingRulesAsync(product.Id, tenantId, cancellationToken);
|
||||
|
||||
await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask);
|
||||
|
||||
// 3. 组装 DTO
|
||||
var attrOptions = attrOptionsTask.Result.ToLookup(x => x.AttributeGroupId);
|
||||
var addonOptions = addonOptionsTask.Result.ToLookup(x => x.AddonGroupId);
|
||||
|
||||
var detail = new ProductDetailDto
|
||||
{
|
||||
Product = ProductMapping.ToDto(product),
|
||||
Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(),
|
||||
AttributeGroups = attrGroupsTask.Result
|
||||
.Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList()))
|
||||
.ToList(),
|
||||
AddonGroups = addonGroupsTask.Result
|
||||
.Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList()))
|
||||
.ToList(),
|
||||
MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(),
|
||||
PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList()
|
||||
};
|
||||
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Products.Commands;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
using TakeoutSaaS.Application.App.Products.Queries;
|
||||
using TakeoutSaaS.Domain.Products.Enums;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 商品上架处理器。
|
||||
/// </summary>
|
||||
public sealed class PublishProductCommandHandler(
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<PublishProductCommandHandler> logger)
|
||||
: IRequestHandler<PublishProductCommand, ProductDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ProductDto?> Handle(PublishProductCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取商品
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
|
||||
if (product is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 校验 SKU 可售
|
||||
var skus = await productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken);
|
||||
if (skus.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "请先配置可售 SKU 后再上架");
|
||||
}
|
||||
|
||||
// 3. 上架
|
||||
product.Status = ProductStatus.OnSale;
|
||||
await productRepository.UpdateProductAsync(product, cancellationToken);
|
||||
await productRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("商品上架 {ProductId}", product.Id);
|
||||
|
||||
return ProductMapping.ToDto(product);
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,7 @@ public sealed class SearchProductsQueryHandler(
|
||||
public async Task<PagedResult<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();
|
||||
}
|
||||
var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken);
|
||||
|
||||
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
|
||||
var paged = sorted
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Products.Commands;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
using TakeoutSaaS.Domain.Products.Enums;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 商品下架处理器。
|
||||
/// </summary>
|
||||
public sealed class UnpublishProductCommandHandler(
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UnpublishProductCommandHandler> logger)
|
||||
: IRequestHandler<UnpublishProductCommand, ProductDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ProductDto?> Handle(UnpublishProductCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取商品
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
|
||||
if (product is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 下架
|
||||
product.Status = ProductStatus.OffShelf;
|
||||
await productRepository.UpdateProductAsync(product, cancellationToken);
|
||||
await productRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("商品下架 {ProductId}", product.Id);
|
||||
|
||||
return ProductMapping.ToDto(product);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
using TakeoutSaaS.Domain.Products.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products;
|
||||
|
||||
/// <summary>
|
||||
/// 商品映射辅助。
|
||||
/// </summary>
|
||||
public static class ProductMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射 SPU DTO。
|
||||
/// </summary>
|
||||
/// <param name="product">商品实体。</param>
|
||||
/// <returns>DTO。</returns>
|
||||
public static ProductDto ToDto(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,
|
||||
CreatedAt = product.CreatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射 SKU DTO。
|
||||
/// </summary>
|
||||
public static ProductSkuDto ToDto(ProductSku sku) => new()
|
||||
{
|
||||
Id = sku.Id,
|
||||
ProductId = sku.ProductId,
|
||||
SkuCode = sku.SkuCode,
|
||||
Barcode = sku.Barcode,
|
||||
Price = sku.Price,
|
||||
OriginalPrice = sku.OriginalPrice,
|
||||
StockQuantity = sku.StockQuantity,
|
||||
Weight = sku.Weight,
|
||||
AttributesJson = sku.AttributesJson,
|
||||
SortOrder = sku.SortOrder
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射规格组 DTO。
|
||||
/// </summary>
|
||||
public static ProductAttributeGroupDto ToDto(ProductAttributeGroup group, IReadOnlyList<ProductAttributeOption> options) => new()
|
||||
{
|
||||
Id = group.Id,
|
||||
ProductId = group.ProductId,
|
||||
Name = group.Name,
|
||||
SelectionType = (int)group.SelectionType,
|
||||
SortOrder = group.SortOrder,
|
||||
Options = options.Select(ToDto).ToList()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射规格选项 DTO。
|
||||
/// </summary>
|
||||
public static ProductAttributeOptionDto ToDto(ProductAttributeOption option) => new()
|
||||
{
|
||||
Id = option.Id,
|
||||
AttributeGroupId = option.AttributeGroupId,
|
||||
Name = option.Name,
|
||||
SortOrder = option.SortOrder
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射加料组 DTO。
|
||||
/// </summary>
|
||||
public static ProductAddonGroupDto ToDto(ProductAddonGroup group, IReadOnlyList<ProductAddonOption> options) => new()
|
||||
{
|
||||
Id = group.Id,
|
||||
ProductId = group.ProductId,
|
||||
Name = group.Name,
|
||||
MinSelect = group.MinSelect ?? 0,
|
||||
MaxSelect = group.MaxSelect ?? 0,
|
||||
SortOrder = group.SortOrder,
|
||||
Options = options.Select(ToDto).ToList()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射加料选项 DTO。
|
||||
/// </summary>
|
||||
public static ProductAddonOptionDto ToDto(ProductAddonOption option) => new()
|
||||
{
|
||||
Id = option.Id,
|
||||
AddonGroupId = option.AddonGroupId,
|
||||
Name = option.Name,
|
||||
ExtraPrice = option.ExtraPrice,
|
||||
SortOrder = option.SortOrder
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射媒资 DTO。
|
||||
/// </summary>
|
||||
public static ProductMediaAssetDto ToDto(ProductMediaAsset asset) => new()
|
||||
{
|
||||
Id = asset.Id,
|
||||
ProductId = asset.ProductId,
|
||||
MediaType = asset.MediaType,
|
||||
Url = asset.Url,
|
||||
Caption = asset.Caption,
|
||||
SortOrder = asset.SortOrder
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射价格策略 DTO。
|
||||
/// </summary>
|
||||
public static ProductPricingRuleDto ToDto(ProductPricingRule rule) => new()
|
||||
{
|
||||
Id = rule.Id,
|
||||
ProductId = rule.ProductId,
|
||||
RuleType = rule.RuleType,
|
||||
Price = rule.Price,
|
||||
ConditionsJson = rule.ConditionsJson,
|
||||
WeekdaysJson = rule.WeekdaysJson
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 商品全量详情查询。
|
||||
/// </summary>
|
||||
public sealed record GetProductDetailQuery : IRequest<ProductDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Products.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 上架商品命令验证器。
|
||||
/// </summary>
|
||||
public sealed class PublishProductCommandValidator : AbstractValidator<PublishProductCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public PublishProductCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProductId).GreaterThan(0);
|
||||
RuleFor(x => x.Reason).MaximumLength(256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Products.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Products.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 下架商品命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UnpublishProductCommandValidator : AbstractValidator<UnpublishProductCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public UnpublishProductCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProductId).GreaterThan(0);
|
||||
RuleFor(x => x.Reason).MaximumLength(256);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public interface IProductRepository
|
||||
/// <summary>
|
||||
/// 按分类与状态筛选商品列表。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户下的商品分类。
|
||||
|
||||
@@ -27,12 +27,17 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Products
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (storeId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
if (categoryId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.CategoryId == categoryId.Value);
|
||||
|
||||
Reference in New Issue
Block a user