feat(product): add product label management backend
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s

This commit is contained in:
2026-02-21 10:18:48 +08:00
parent 93bc072b8d
commit ad65ef3bf6
21 changed files with 9480 additions and 6 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 修改商品标签状态命令。
/// </summary>
public sealed class ChangeProductLabelStatusCommand : IRequest<ProductLabelItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 标签 ID。
/// </summary>
public long LabelId { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 删除商品标签命令。
/// </summary>
public sealed class DeleteProductLabelCommand : IRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 标签 ID。
/// </summary>
public long LabelId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 保存商品标签命令。
/// </summary>
public sealed class SaveProductLabelCommand : IRequest<ProductLabelItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 标签 ID编辑时传
/// </summary>
public long? LabelId { get; init; }
/// <summary>
/// 标签名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 标签颜色HEX
/// </summary>
public string Color { get; init; } = "#1890ff";
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 商品标签列表项 DTO。
/// </summary>
public sealed class ProductLabelItemDto
{
/// <summary>
/// 标签 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 标签名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 标签颜色HEX
/// </summary>
public string Color { get; init; } = "#1890ff";
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 关联商品数量。
/// </summary>
public int ProductCount { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,48 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
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 ChangeProductLabelStatusCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeProductLabelStatusCommand, ProductLabelItemDto>
{
/// <inheritdoc />
public async Task<ProductLabelItemDto> Handle(ChangeProductLabelStatusCommand request, CancellationToken cancellationToken)
{
// 1. 解析状态并校验标签归属。
if (!ProductLabelMapping.TryParseStatus(request.Status, out var isEnabled))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var existing = await productRepository.FindLabelByIdAsync(request.LabelId, tenantId, cancellationToken);
if (existing is null || existing.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "标签不存在");
}
// 2. 保存状态变更。
existing.IsEnabled = isEnabled;
await productRepository.UpdateLabelAsync(existing, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
// 3. 返回最新快照。
var productCounts = await productRepository.CountLabelProductsByLabelIdsAsync(
tenantId,
request.StoreId,
[existing.Id],
cancellationToken);
return ProductLabelDtoFactory.ToDto(existing, productCounts);
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
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 DeleteProductLabelCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteProductLabelCommand>
{
/// <inheritdoc />
public async Task Handle(DeleteProductLabelCommand request, CancellationToken cancellationToken)
{
// 1. 校验标签存在且归属当前门店。
var tenantId = tenantProvider.GetCurrentTenantId();
var existing = await productRepository.FindLabelByIdAsync(request.LabelId, tenantId, cancellationToken);
if (existing is null || existing.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "标签不存在");
}
// 2. 删除关联与标签主记录。
await productRepository.RemoveLabelProductsAsync(existing.Id, tenantId, request.StoreId, cancellationToken);
await productRepository.DeleteLabelAsync(existing.Id, tenantId, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,68 @@
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 GetProductLabelListQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetProductLabelListQuery, IReadOnlyList<ProductLabelItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductLabelItemDto>> Handle(GetProductLabelListQuery request, CancellationToken cancellationToken)
{
// 1. 读取门店标签。
var tenantId = tenantProvider.GetCurrentTenantId();
var labels = await productRepository.GetLabelsByStoreAsync(tenantId, request.StoreId, cancellationToken);
if (labels.Count == 0)
{
return [];
}
// 2. 按状态与关键字过滤。
IEnumerable<ProductLabel> filtered = labels;
if (!string.IsNullOrWhiteSpace(request.Status))
{
if (!ProductLabelMapping.TryParseStatus(request.Status, out var isEnabled))
{
return [];
}
filtered = filtered.Where(item => item.IsEnabled == isEnabled);
}
var normalizedKeyword = request.Keyword?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
filtered = filtered.Where(item => item.Name.ToLower().Contains(normalizedKeyword));
}
var filteredList = filtered
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToList();
if (filteredList.Count == 0)
{
return [];
}
// 3. 统计关联商品数量并映射 DTO。
var labelIds = filteredList.Select(item => item.Id).ToList();
var productCounts = await productRepository.CountLabelProductsByLabelIdsAsync(
tenantId,
request.StoreId,
labelIds,
cancellationToken);
return filteredList
.Select(label => ProductLabelDtoFactory.ToDto(label, productCounts))
.ToList();
}
}

View File

@@ -0,0 +1,97 @@
using MediatR;
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.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 保存商品标签命令处理器。
/// </summary>
public sealed class SaveProductLabelCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveProductLabelCommand, ProductLabelItemDto>
{
/// <inheritdoc />
public async Task<ProductLabelItemDto> Handle(SaveProductLabelCommand request, CancellationToken cancellationToken)
{
// 1. 归一化并校验输入。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedName = request.Name.Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
throw new BusinessException(ErrorCodes.BadRequest, "标签名称不能为空");
}
if (!ProductLabelMapping.TryParseStatus(request.Status, out var isEnabled))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
if (!ProductLabelMapping.TryNormalizeColor(request.Color, out var normalizedColor))
{
throw new BusinessException(ErrorCodes.BadRequest, "标签颜色格式不合法");
}
var normalizedSort = Math.Max(0, request.Sort);
// 2. 校验门店内名称唯一。
var isDuplicate = await productRepository.ExistsLabelNameAsync(
tenantId,
request.StoreId,
normalizedName,
request.LabelId,
cancellationToken);
if (isDuplicate)
{
throw new BusinessException(ErrorCodes.Conflict, "标签名称已存在");
}
// 3. 创建或更新标签。
ProductLabel label;
if (!request.LabelId.HasValue)
{
label = new ProductLabel
{
StoreId = request.StoreId,
Name = normalizedName,
Color = normalizedColor,
SortOrder = normalizedSort,
IsEnabled = isEnabled
};
await productRepository.AddLabelAsync(label, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
}
else
{
var existing = await productRepository.FindLabelByIdAsync(request.LabelId.Value, tenantId, cancellationToken);
if (existing is null || existing.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "标签不存在");
}
existing.Name = normalizedName;
existing.Color = normalizedColor;
existing.SortOrder = normalizedSort;
existing.IsEnabled = isEnabled;
label = existing;
await productRepository.UpdateLabelAsync(label, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
}
// 4. 回读关联数量并返回快照。
var productCounts = await productRepository.CountLabelProductsByLabelIdsAsync(
tenantId,
request.StoreId,
[label.Id],
cancellationToken);
return ProductLabelDtoFactory.ToDto(label, productCounts);
}
}

View File

@@ -0,0 +1,33 @@
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Entities;
namespace TakeoutSaaS.Application.App.Products;
/// <summary>
/// 商品标签 DTO 映射工厂。
/// </summary>
internal static class ProductLabelDtoFactory
{
/// <summary>
/// 将标签实体映射为页面 DTO。
/// </summary>
public static ProductLabelItemDto ToDto(
ProductLabel label,
IReadOnlyDictionary<long, int> productCounts)
{
var productCount = productCounts.TryGetValue(label.Id, out var count)
? count
: 0;
return new ProductLabelItemDto
{
Id = label.Id,
Name = label.Name,
Color = label.Color,
Sort = label.SortOrder,
Status = ProductLabelMapping.ToStatusText(label.IsEnabled),
ProductCount = productCount,
UpdatedAt = label.UpdatedAt ?? label.CreatedAt
};
}
}

View File

@@ -0,0 +1,76 @@
using System.Text.RegularExpressions;
namespace TakeoutSaaS.Application.App.Products;
/// <summary>
/// 商品标签映射辅助。
/// </summary>
internal static partial class ProductLabelMapping
{
/// <summary>
/// 解析状态字符串。
/// </summary>
public static bool TryParseStatus(string? status, out bool isEnabled)
{
var normalized = status?.Trim().ToLowerInvariant();
switch (normalized)
{
case "enabled":
isEnabled = true;
return true;
case "disabled":
isEnabled = false;
return true;
default:
isEnabled = true;
return false;
}
}
/// <summary>
/// 状态转字符串。
/// </summary>
public static string ToStatusText(bool isEnabled)
{
return isEnabled ? "enabled" : "disabled";
}
/// <summary>
/// 归一化颜色值。
/// </summary>
public static bool TryNormalizeColor(string? color, out string normalized)
{
var candidate = (color ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(candidate))
{
normalized = "#1890ff";
return true;
}
if (!candidate.StartsWith('#'))
{
candidate = $"#{candidate}";
}
if (ShortHexColorRegex().IsMatch(candidate))
{
normalized = $"#{candidate[1]}{candidate[1]}{candidate[2]}{candidate[2]}{candidate[3]}{candidate[3]}";
return true;
}
if (LongHexColorRegex().IsMatch(candidate))
{
normalized = candidate;
return true;
}
normalized = "#1890ff";
return false;
}
[GeneratedRegex("^#[0-9a-f]{3}$", RegexOptions.Compiled)]
private static partial Regex ShortHexColorRegex();
[GeneratedRegex("^#[0-9a-f]{6}$", RegexOptions.Compiled)]
private static partial Regex LongHexColorRegex();
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 查询商品标签列表。
/// </summary>
public sealed class GetProductLabelListQuery : IRequest<IReadOnlyList<ProductLabelItemDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string? Status { get; init; }
}