feat: 商品上架/下架与全量详情支持

This commit is contained in:
2025-12-04 10:03:42 +08:00
parent 9220e0ca36
commit de5f13ec83
25 changed files with 785 additions and 36 deletions

View File

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

View File

@@ -112,6 +112,7 @@
"product:read",
"product:update",
"product:delete",
"product:publish",
"order:create",
"order:read",
"order:update",

View File

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

View File

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

View File

@@ -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; } = [];
}

View File

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

View File

@@ -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; } = [];
}

View File

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

View File

@@ -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; } = [];
}

View File

@@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 商品 DTO。
/// 商品 DTO(含 SPU 基础信息)
/// </summary>
public sealed class ProductDto
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
/// 获取租户下的商品分类。

View File

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