feat(product): add product label management backend
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user