feat: 商品模块移除租户上下文依赖

This commit is contained in:
2026-01-29 14:04:20 +00:00
parent b5dfb58a8b
commit 010c2b7043
11 changed files with 95 additions and 71 deletions

View File

@@ -4,24 +4,40 @@ using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 创建商品命令处理器。
/// </summary>
public sealed class CreateProductCommandHandler(IProductRepository productRepository, ILogger<CreateProductCommandHandler> logger)
public sealed class CreateProductCommandHandler(
IProductRepository productRepository,
IStoreRepository storeRepository,
ILogger<CreateProductCommandHandler> logger)
: IRequestHandler<CreateProductCommand, ProductDto>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly IStoreRepository _storeRepository = storeRepository;
private readonly ILogger<CreateProductCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
// 1. 构建实体
// 1. 校验门店存在并解析租户
var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId: null, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
var tenantId = store.TenantId;
// 2. (空行后) 构建实体并写入租户
var product = new Product
{
TenantId = tenantId,
StoreId = request.StoreId,
CategoryId = request.CategoryId,
SpuCode = request.SpuCode.Trim(),
@@ -42,12 +58,12 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
IsFeatured = request.IsFeatured
};
// 2. 持久化
// 3. (空行后) 持久化
await _productRepository.AddProductAsync(product, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("创建商品 {ProductId} - {ProductName}", product.Id, product.Name);
// 3. 返回 DTO
// 4. (空行后) 返回 DTO
return MapToDto(product);
}

View File

@@ -2,7 +2,6 @@ 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;
@@ -11,27 +10,24 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </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);
// 1. 校验存在性(跨租户)
var existing = await _productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (existing == null)
{
return false;
}
// 2. 删除
await _productRepository.DeleteProductAsync(request.ProductId, tenantId, cancellationToken);
// 2. (空行后) 删除
await _productRepository.DeleteProductAsync(request.ProductId, existing.TenantId, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("删除商品 {ProductId}", request.ProductId);

View File

@@ -9,7 +9,9 @@ using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Products.Handlers;
@@ -18,15 +20,20 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class GetStoreMenuQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
IStoreRepository storeRepository,
ILogger<GetStoreMenuQueryHandler> logger)
: IRequestHandler<GetStoreMenuQuery, StoreMenuDto>
{
/// <inheritdoc />
public async Task<StoreMenuDto> Handle(GetStoreMenuQuery request, CancellationToken cancellationToken)
{
// 1. 准备上下文
var tenantId = tenantProvider.GetCurrentTenantId();
// 1. 校验门店存在并解析租户
var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId: null, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
var tenantId = store.TenantId;
var updatedAfterUtc = request.UpdatedAfter?.ToUniversalTime();
// 2. 获取分类
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, true, cancellationToken);

View File

@@ -7,7 +7,6 @@ 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;
@@ -16,29 +15,28 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </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);
// 1. 读取商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
return null;
}
var tenantId = product.TenantId;
// 2. 校验 SKU 可售
// 2. (空行后) 校验 SKU 可售
var skus = await productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken);
if (skus.Count == 0)
{
throw new BusinessException(ErrorCodes.Conflict, "请先配置可售 SKU 后再上架");
}
// 3. 上架
// 3. (空行后) 上架
product.Status = ProductStatus.OnSale;
await productRepository.UpdateProductAsync(product, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Products.Entities;
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;
@@ -15,33 +14,33 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class ReplaceProductAddonsCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductAddonsCommandHandler> logger)
: IRequestHandler<ReplaceProductAddonsCommand, IReadOnlyList<ProductAddonGroupDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroupDto>> Handle(ReplaceProductAddonsCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
// 1. 校验商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
var tenantId = product.TenantId;
// 2. 校验组名唯一
// 2. (空行后) 校验组名唯一
var names = request.AddonGroups.Select(x => x.Name.Trim()).ToList();
if (names.Count != names.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "加料组名称重复");
}
// 3. 替换
// 3. (空行后) 替换
await productRepository.RemoveAddonGroupsAsync(request.ProductId, tenantId, cancellationToken);
// 重新插入组
var groupEntities = request.AddonGroups.Select(g => new ProductAddonGroup
{
TenantId = tenantId,
ProductId = request.ProductId,
Name = g.Name.Trim(),
MinSelect = g.MinSelect,
@@ -57,6 +56,7 @@ public sealed class ReplaceProductAddonsCommandHandler(
var optionEntities = request.AddonGroups
.SelectMany(dto => dto.Options.Select(o => new ProductAddonOption
{
TenantId = tenantId,
AddonGroupId = groupIdLookup[dto],
Name = o.Name.Trim(),
ExtraPrice = o.ExtraPrice,

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Products.Entities;
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;
@@ -15,33 +14,33 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class ReplaceProductAttributesCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductAttributesCommandHandler> logger)
: IRequestHandler<ReplaceProductAttributesCommand, IReadOnlyList<ProductAttributeGroupDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroupDto>> Handle(ReplaceProductAttributesCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
// 1. 校验商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
var tenantId = product.TenantId;
// 2. 组名唯一
// 2. (空行后) 组名唯一
var groupNames = request.AttributeGroups.Select(x => x.Name.Trim()).ToList();
if (groupNames.Count != groupNames.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "规格组名称重复");
}
// 3. 替换
// 3. (空行后) 替换
await productRepository.RemoveAttributeGroupsAsync(request.ProductId, tenantId, cancellationToken);
var groupEntities = request.AttributeGroups.Select(g => new ProductAttributeGroup
{
TenantId = tenantId,
ProductId = request.ProductId,
Name = g.Name.Trim(),
SelectionType = (Domain.Products.Enums.AttributeSelectionType)g.SelectionType,
@@ -59,6 +58,7 @@ public sealed class ReplaceProductAttributesCommandHandler(
var optionEntities = request.AttributeGroups
.SelectMany(dto => dto.Options.Select(o => new ProductAttributeOption
{
TenantId = tenantId,
AttributeGroupId = groupIdLookup[dto],
Name = o.Name.Trim(),
SortOrder = o.SortOrder

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Products.Entities;
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;
@@ -15,26 +14,26 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class ReplaceProductMediaCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductMediaCommandHandler> logger)
: IRequestHandler<ReplaceProductMediaCommand, IReadOnlyList<ProductMediaAssetDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAssetDto>> Handle(ReplaceProductMediaCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
// 1. 校验商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
var tenantId = product.TenantId;
// 2. 替换
// 2. (空行后) 替换
await productRepository.RemoveMediaAssetsAsync(request.ProductId, tenantId, cancellationToken);
var assets = request.MediaAssets.Select(a => new ProductMediaAsset
{
TenantId = tenantId,
ProductId = request.ProductId,
MediaType = a.MediaType,
Url = a.Url.Trim(),

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Products.Entities;
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;
@@ -15,26 +14,26 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class ReplaceProductPricingRulesCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductPricingRulesCommandHandler> logger)
: IRequestHandler<ReplaceProductPricingRulesCommand, IReadOnlyList<ProductPricingRuleDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRuleDto>> Handle(ReplaceProductPricingRulesCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
// 1. 校验商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
var tenantId = product.TenantId;
// 2. 替换
// 2. (空行后) 替换
await productRepository.RemovePricingRulesAsync(request.ProductId, tenantId, cancellationToken);
var rules = request.PricingRules.Select(r => new ProductPricingRule
{
TenantId = tenantId,
ProductId = request.ProductId,
RuleType = r.RuleType,
ConditionsJson = r.ConditionsJson.Trim(),

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Products.Entities;
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;
@@ -15,32 +14,32 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class ReplaceProductSkusCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductSkusCommandHandler> logger)
: IRequestHandler<ReplaceProductSkusCommand, IReadOnlyList<ProductSkuDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSkuDto>> Handle(ReplaceProductSkusCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品存在
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
// 1. 校验商品存在(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
var tenantId = product.TenantId;
// 2. 校验 SKU 唯一性
// 2. (空行后) 校验 SKU 唯一性
var codes = request.Skus.Select(x => x.SkuCode.Trim()).ToList();
if (codes.Count != codes.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "SKU 编码重复");
}
// 3. 替换
// 3. (空行后) 替换
await productRepository.RemoveSkusAsync(request.ProductId, tenantId, cancellationToken);
var entities = request.Skus.Select(x => new ProductSku
{
TenantId = tenantId,
ProductId = request.ProductId,
SkuCode = x.SkuCode.Trim(),
Barcode = x.Barcode?.Trim(),

View File

@@ -4,7 +4,6 @@ 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;
@@ -13,22 +12,20 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </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);
// 1. 读取商品(跨租户)
var product = await productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (product is null)
{
return null;
}
// 2. 下架
// 2. (空行后) 下架
product.Status = ProductStatus.OffShelf;
await productRepository.UpdateProductAsync(product, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);

View File

@@ -4,7 +4,9 @@ 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;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Products.Handlers;
@@ -13,26 +15,37 @@ namespace TakeoutSaaS.Application.App.Products.Handlers;
/// </summary>
public sealed class UpdateProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
IStoreRepository storeRepository,
ILogger<UpdateProductCommandHandler> logger)
: IRequestHandler<UpdateProductCommand, ProductDto?>
{
private readonly IProductRepository _productRepository = productRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly IStoreRepository _storeRepository = storeRepository;
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);
// 1. 读取商品(跨租户)
var existing = await _productRepository.FindByIdAsync(request.ProductId, cancellationToken);
if (existing == null)
{
return null;
}
var tenantId = existing.TenantId;
// 2. 更新字段
// 2. (空行后) 校验门店存在且属于同租户
var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId: null, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
if (store.TenantId != tenantId)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "门店与商品不属于同一租户");
}
// 3. (空行后) 更新字段
existing.StoreId = request.StoreId;
existing.CategoryId = request.CategoryId;
existing.SpuCode = request.SpuCode.Trim();
@@ -52,12 +65,12 @@ public sealed class UpdateProductCommandHandler(
existing.EnableDelivery = request.EnableDelivery;
existing.IsFeatured = request.IsFeatured;
// 3. 持久化
// 4. (空行后) 持久化
await _productRepository.UpdateProductAsync(existing, cancellationToken);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("更新商品 {ProductId} - {ProductName}", existing.Id, existing.Name);
// 4. 返回 DTO
// 5. (空行后) 返回 DTO
return MapToDto(existing);
}