From 1b3525862ae336c1a0e1a45a5ca1aafd3afa36cc Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Fri, 20 Feb 2026 18:45:48 +0800
Subject: [PATCH] feat: implement tenant product category management APIs
---
.../Product/ProductCategoryContracts.cs | 330 +
.../Controllers/ProductCategoryController.cs | 282 +
.../Commands/BindCategoryProductsCommand.cs | 25 +
.../ChangeProductCategoryStatusCommand.cs | 25 +
.../Commands/DeleteProductCategoryCommand.cs | 19 +
.../Commands/SaveProductCategoryCommand.cs | 50 +
.../Commands/SortProductCategoryCommand.cs | 36 +
.../Commands/UnbindCategoryProductCommand.cs | 24 +
.../Dto/ProductCategoryListItemDto.cs | 31 +
.../Dto/ProductCategoryManageItemDto.cs | 27 +
.../ProductCategoryProductBindResultDto.cs | 22 +
.../App/Products/Dto/ProductPickerItemDto.cs | 47 +
.../BindCategoryProductsCommandHandler.cs | 58 +
...angeProductCategoryStatusCommandHandler.cs | 56 +
.../DeleteProductCategoryCommandHandler.cs | 47 +
.../GetProductCategoryListQueryHandler.cs | 41 +
...etProductCategoryManageListQueryHandler.cs | 72 +
.../SaveProductCategoryCommandHandler.cs | 100 +
.../SearchProductPickerQueryHandler.cs | 56 +
.../SortProductCategoryCommandHandler.cs | 75 +
.../UnbindCategoryProductCommandHandler.cs | 53 +
.../Products/ProductCategoryManageMapping.cs | 114 +
.../Queries/GetProductCategoryListQuery.cs | 15 +
.../GetProductCategoryManageListQuery.cs | 25 +
.../Queries/SearchProductPickerQuery.cs | 30 +
.../BindCategoryProductsCommandValidator.cs | 21 +
...geProductCategoryStatusCommandValidator.cs | 22 +
.../DeleteProductCategoryCommandValidator.cs | 19 +
.../GetProductCategoryListQueryValidator.cs | 18 +
...ProductCategoryManageListQueryValidator.cs | 22 +
.../SaveProductCategoryCommandValidator.cs | 33 +
.../SearchProductPickerQueryValidator.cs | 21 +
.../SortProductCategoryCommandValidator.cs | 35 +
.../UnbindCategoryProductCommandValidator.cs | 20 +
.../Products/Entities/ProductCategory.cs | 10 +
.../Repositories/IProductRepository.cs | 30 +
.../App/Persistence/TakeoutAppDbContext.cs | 2 +
.../App/Repositories/EfProductRepository.cs | 118 +
...ProductCategoryIconAndChannels.Designer.cs | 7866 +++++++++++++++++
...02334_AddProductCategoryIconAndChannels.cs | 42 +
.../TakeoutAppDbContextModelSnapshot.cs | 14 +-
41 files changed, 9951 insertions(+), 2 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductCategoryContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/ProductCategoryController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/BindCategoryProductsCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ChangeProductCategoryStatusCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductCategoryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductCategoryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/SortProductCategoryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/UnbindCategoryProductCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryListItemDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryManageItemDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryProductBindResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPickerItemDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/BindCategoryProductsCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ChangeProductCategoryStatusCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductCategoryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductCategoryListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductCategoryManageListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductCategoryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductPickerQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/SortProductCategoryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnbindCategoryProductCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/ProductCategoryManageMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductCategoryListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductCategoryManageListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/SearchProductPickerQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/BindCategoryProductsCommandValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ChangeProductCategoryStatusCommandValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/DeleteProductCategoryCommandValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/GetProductCategoryListQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/GetProductCategoryManageListQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/SaveProductCategoryCommandValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/SearchProductPickerQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/SortProductCategoryCommandValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/UnbindCategoryProductCommandValidator.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260220102334_AddProductCategoryIconAndChannels.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260220102334_AddProductCategoryIconAndChannels.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductCategoryContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductCategoryContracts.cs
new file mode 100644
index 0000000..ce16a69
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductCategoryContracts.cs
@@ -0,0 +1,330 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Product;
+
+///
+/// 分类列表查询请求。
+///
+public sealed class ProductCategoryListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+}
+
+///
+/// 分类管理列表查询请求。
+///
+public sealed class ProductCategoryManageListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 关键字。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string? Status { get; set; }
+}
+
+///
+/// 保存分类请求。
+///
+public sealed class SaveProductCategoryRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID(编辑时传)。
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// 分类名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 分类描述。
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// 图标地址。
+ ///
+ public string Icon { get; set; } = string.Empty;
+
+ ///
+ /// 渠道列表。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 排序值。
+ ///
+ public int Sort { get; set; }
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+}
+
+///
+/// 删除分类请求。
+///
+public sealed class DeleteProductCategoryRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+}
+
+///
+/// 变更分类状态请求。
+///
+public sealed class ChangeProductCategoryStatusRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+}
+
+///
+/// 分类排序请求项。
+///
+public sealed class ProductCategorySortItemRequest
+{
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 排序值。
+ ///
+ public int Sort { get; set; }
+}
+
+///
+/// 分类排序请求。
+///
+public sealed class SortProductCategoryRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 排序项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 分类绑定商品请求。
+///
+public sealed class BindCategoryProductsRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID 列表。
+ ///
+ public List ProductIds { get; set; } = [];
+}
+
+///
+/// 分类解绑商品请求。
+///
+public sealed class UnbindCategoryProductRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+}
+
+///
+/// 商品选择器查询请求。
+///
+public sealed class ProductPickerListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string? CategoryId { get; set; }
+
+ ///
+ /// 关键字。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 返回数量上限。
+ ///
+ public int? Limit { get; set; }
+}
+
+///
+/// 分类列表项响应。
+///
+public class ProductCategoryListItemResponse
+{
+ ///
+ /// 分类 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 分类名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 商品数。
+ ///
+ public int ProductCount { get; set; }
+
+ ///
+ /// 排序。
+ ///
+ public int Sort { get; set; }
+}
+
+///
+/// 分类管理项响应。
+///
+public sealed class ProductCategoryManageItemResponse : ProductCategoryListItemResponse
+{
+ ///
+ /// 分类描述。
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// 图标地址。
+ ///
+ public string Icon { get; set; } = string.Empty;
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+
+ ///
+ /// 渠道列表。
+ ///
+ public List Channels { get; set; } = [];
+}
+
+///
+/// 分类绑定结果响应。
+///
+public sealed class ProductBindResultResponse
+{
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 成功条数。
+ ///
+ public int SuccessCount { get; set; }
+
+ ///
+ /// 失败条数。
+ ///
+ public int FailedCount { get; set; }
+}
+
+///
+/// 商品选择器项响应。
+///
+public sealed class ProductPickerItemResponse
+{
+ ///
+ /// 商品 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 分类名称。
+ ///
+ public string CategoryName { get; set; } = string.Empty;
+
+ ///
+ /// 商品名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// SPU 编码。
+ ///
+ public string SpuCode { get; set; } = string.Empty;
+
+ ///
+ /// 状态(on_sale/off_shelf/sold_out)。
+ ///
+ public string Status { get; set; } = "off_shelf";
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductCategoryController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductCategoryController.cs
new file mode 100644
index 0000000..19a068a
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductCategoryController.cs
@@ -0,0 +1,282 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Products.Commands;
+using TakeoutSaaS.Application.App.Products.Dto;
+using TakeoutSaaS.Application.App.Products.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Product;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 租户端分类管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/product")]
+public sealed class ProductCategoryController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ ///
+ /// 分类列表(侧栏)。
+ ///
+ [HttpGet("category/list")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetCategoryList(
+ [FromQuery] ProductCategoryListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new GetProductCategoryListQuery
+ {
+ StoreId = storeId
+ }, cancellationToken);
+
+ return ApiResponse>.Ok(result.Select(MapCategoryListItem).ToList());
+ }
+
+ ///
+ /// 分类管理列表。
+ ///
+ [HttpGet("category/manage/list")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetCategoryManageList(
+ [FromQuery] ProductCategoryManageListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new GetProductCategoryManageListQuery
+ {
+ StoreId = storeId,
+ Keyword = request.Keyword,
+ Status = request.Status
+ }, cancellationToken);
+
+ return ApiResponse>.Ok(result.Select(MapCategoryManageItem).ToList());
+ }
+
+ ///
+ /// 保存分类。
+ ///
+ [HttpPost("category/manage/save")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SaveCategory(
+ [FromBody] SaveProductCategoryRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new SaveProductCategoryCommand
+ {
+ StoreId = storeId,
+ CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
+ Name = request.Name,
+ Description = request.Description,
+ Icon = request.Icon,
+ Channels = request.Channels,
+ Sort = request.Sort,
+ Status = request.Status
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapCategoryManageItem(result));
+ }
+
+ ///
+ /// 删除分类。
+ ///
+ [HttpPost("category/manage/delete")]
+ [ProducesResponseType(typeof(ApiResponse