diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
new file mode 100644
index 0000000..8243d2c
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
@@ -0,0 +1,447 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Product;
+
+///
+/// 商品列表查询请求。
+///
+public sealed class ProductListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 分类 ID。
+ ///
+ public string? CategoryId { get; set; }
+
+ ///
+ /// 关键字。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 状态(on_sale/off_shelf/sold_out)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 类型(single/combo)。
+ ///
+ public string? Kind { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 商品详情查询请求。
+///
+public sealed class ProductDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+}
+
+///
+/// 保存商品请求。
+///
+public sealed class SaveProductRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID(编辑时传)。
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// 分类 ID。
+ ///
+ public string CategoryId { get; set; } = string.Empty;
+
+ ///
+ /// 商品类型(single/combo)。
+ ///
+ public string Kind { get; set; } = "single";
+
+ ///
+ /// 商品名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 副标题。
+ ///
+ public string Subtitle { get; set; } = string.Empty;
+
+ ///
+ /// 描述。
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// 售价。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 原价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 库存。
+ ///
+ public int Stock { get; set; }
+
+ ///
+ /// 标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 状态(on_sale/off_shelf/sold_out)。
+ ///
+ public string Status { get; set; } = "off_shelf";
+
+ ///
+ /// 上架方式(draft/now/scheduled)。
+ ///
+ public string ShelfMode { get; set; } = "draft";
+
+ ///
+ /// SPU 编码(可选)。
+ ///
+ public string? SpuCode { get; set; }
+
+ ///
+ /// 定时上架时间。
+ ///
+ public string? TimedOnShelfAt { get; set; }
+
+ ///
+ /// 商品图片地址列表。
+ ///
+ public List ImageUrls { get; set; } = [];
+}
+
+///
+/// 删除商品请求。
+///
+public sealed class DeleteProductRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+}
+
+///
+/// 商品状态变更请求。
+///
+public sealed class ChangeProductStatusRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 状态(on_sale/off_shelf/sold_out)。
+ ///
+ public string Status { get; set; } = "off_shelf";
+}
+
+///
+/// 商品沽清请求。
+///
+public sealed class SoldoutProductRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 沽清模式(today/timed/permanent)。
+ ///
+ public string Mode { get; set; } = "today";
+
+ ///
+ /// 剩余可售。
+ ///
+ public int RemainStock { get; set; }
+
+ ///
+ /// 沽清原因。
+ ///
+ public string Reason { get; set; } = string.Empty;
+
+ ///
+ /// 恢复时间。
+ ///
+ public string? RecoverAt { get; set; }
+
+ ///
+ /// 同步平台。
+ ///
+ public bool SyncToPlatform { get; set; } = true;
+
+ ///
+ /// 通知店长。
+ ///
+ public bool NotifyManager { get; set; }
+}
+
+///
+/// 批量操作请求。
+///
+public sealed class BatchProductActionRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 动作(batch_on/batch_off/batch_delete/batch_soldout)。
+ ///
+ public string Action { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID 列表。
+ ///
+ public List ProductIds { get; set; } = [];
+
+ ///
+ /// 剩余可售(沽清时)。
+ ///
+ public int? RemainStock { get; set; }
+
+ ///
+ /// 原因(沽清时)。
+ ///
+ public string? Reason { get; set; }
+
+ ///
+ /// 恢复时间(沽清时)。
+ ///
+ public string? RecoverAt { get; set; }
+
+ ///
+ /// 同步平台(沽清时)。
+ ///
+ public bool? SyncToPlatform { get; set; }
+
+ ///
+ /// 通知店长(沽清时)。
+ ///
+ public bool? NotifyManager { get; set; }
+}
+
+///
+/// 商品列表响应。
+///
+public sealed class ProductListResultResponse
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// 商品列表项响应。
+///
+public class ProductListItemResponse
+{
+ ///
+ /// 商品 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 ImageUrl { get; set; } = string.Empty;
+
+ ///
+ /// 商品类型(single/combo)。
+ ///
+ public string Kind { get; set; } = "single";
+
+ ///
+ /// 商品名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 原价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 月销量。
+ ///
+ public int SalesMonthly { get; set; }
+
+ ///
+ /// 沽清模式。
+ ///
+ public string? SoldoutMode { get; set; }
+
+ ///
+ /// SPU 编码。
+ ///
+ public string SpuCode { get; set; } = string.Empty;
+
+ ///
+ /// 状态(on_sale/off_shelf/sold_out)。
+ ///
+ public string Status { get; set; } = "off_shelf";
+
+ ///
+ /// 库存。
+ ///
+ public int Stock { get; set; }
+
+ ///
+ /// 副标题。
+ ///
+ public string Subtitle { get; set; } = string.Empty;
+
+ ///
+ /// 标签。
+ ///
+ public List Tags { get; set; } = [];
+}
+
+///
+/// 商品详情响应。
+///
+public sealed class ProductDetailResponse : ProductListItemResponse
+{
+ ///
+ /// 商品图片列表。
+ ///
+ public List ImageUrls { get; set; } = [];
+
+ ///
+ /// 商品描述。
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// 是否通知店长。
+ ///
+ public bool NotifyManager { get; set; }
+
+ ///
+ /// 恢复时间。
+ ///
+ public string? RecoverAt { get; set; }
+
+ ///
+ /// 剩余可售。
+ ///
+ public int RemainStock { get; set; }
+
+ ///
+ /// 沽清原因。
+ ///
+ public string SoldoutReason { get; set; } = string.Empty;
+
+ ///
+ /// 是否同步平台。
+ ///
+ public bool SyncToPlatform { get; set; }
+}
+
+///
+/// 批量操作响应。
+///
+public sealed class BatchProductActionResultResponse
+{
+ ///
+ /// 动作。
+ ///
+ public string Action { get; set; } = string.Empty;
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 成功条数。
+ ///
+ public int SuccessCount { get; set; }
+
+ ///
+ /// 失败条数。
+ ///
+ public int FailedCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
new file mode 100644
index 0000000..4ff31ae
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
@@ -0,0 +1,758 @@
+using System.Globalization;
+using System.Text.Json;
+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.Domain.Products.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+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 ProductController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ ///
+ /// 商品列表。
+ ///
+ [HttpGet("list")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] ProductListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var (status, isSoldOut) = ParseUiStatusFilter(request.Status);
+ var kind = ParseKindOrNull(request.Kind);
+
+ var result = await mediator.Send(new SearchProductsQuery
+ {
+ StoreId = storeId,
+ CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
+ Keyword = request.Keyword,
+ Status = status,
+ IsSoldOut = isSoldOut,
+ Kind = kind,
+ Page = Math.Max(1, request.Page),
+ PageSize = Math.Clamp(request.PageSize, 1, 200)
+ }, cancellationToken);
+
+ var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
+ return ApiResponse.Ok(new ProductListResultResponse
+ {
+ Items = result.Items.Select(item => MapListItem(item, categoryNameLookup)).ToList(),
+ Total = result.TotalCount,
+ Page = result.Page,
+ PageSize = result.PageSize
+ });
+ }
+
+ ///
+ /// 商品详情。
+ ///
+ [HttpGet("detail")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] ProductDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
+ var product = await mediator.Send(new GetProductByIdQuery
+ {
+ ProductId = productId
+ }, cancellationToken);
+
+ if (product is null || product.StoreId != storeId)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ }
+
+ var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(product, categoryNameLookup));
+ }
+
+ ///
+ /// 新增或编辑商品。
+ ///
+ [HttpPost("save")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Save(
+ [FromBody] SaveProductRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId));
+ var kind = ParseKind(request.Kind);
+ var productId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
+ var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
+ var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
+ var imageUrls = NormalizeImageUrls(request.ImageUrls);
+
+ ProductDto? existing = null;
+ if (productId.HasValue)
+ {
+ existing = await mediator.Send(new GetProductByIdQuery
+ {
+ ProductId = productId.Value
+ }, cancellationToken);
+
+ if (existing is null || existing.StoreId != storeId)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ }
+ }
+
+ var tagsJson = SerializeTagsJson(request.Tags);
+ var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
+ var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
+ int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null;
+ var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;
+
+ ProductDto? saved;
+ if (existing is null)
+ {
+ saved = await mediator.Send(new CreateProductCommand
+ {
+ StoreId = storeId,
+ CategoryId = categoryId,
+ SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
+ Name = request.Name.Trim(),
+ Subtitle = request.Subtitle?.Trim(),
+ Description = request.Description?.Trim(),
+ Price = request.Price,
+ OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
+ StockQuantity = Math.Max(0, request.Stock),
+ Status = normalizedStatus,
+ Kind = kind,
+ TagsJson = tagsJson,
+ TimedOnShelfAt = timedOnShelfAt,
+ SoldoutMode = soldoutMode,
+ RecoverAt = recoverAt,
+ RemainStock = remainStock,
+ SoldoutReason = soldoutReason,
+ SyncToPlatform = existing?.SyncToPlatform ?? true,
+ NotifyManager = existing?.NotifyManager ?? false,
+ CoverImage = imageUrls.FirstOrDefault(),
+ GalleryImages = string.Join(',', imageUrls)
+ }, cancellationToken);
+ }
+ else
+ {
+ saved = await mediator.Send(new UpdateProductCommand
+ {
+ ProductId = existing.Id,
+ StoreId = storeId,
+ CategoryId = categoryId,
+ SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
+ Name = request.Name.Trim(),
+ Subtitle = request.Subtitle?.Trim(),
+ Unit = existing.Unit,
+ Price = request.Price,
+ OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
+ StockQuantity = Math.Max(0, request.Stock),
+ MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
+ Status = normalizedStatus,
+ Kind = kind,
+ SalesMonthly = existing.SalesMonthly,
+ TagsJson = tagsJson,
+ SoldoutMode = soldoutMode,
+ RecoverAt = recoverAt,
+ RemainStock = remainStock,
+ SoldoutReason = soldoutReason,
+ SyncToPlatform = existing.SyncToPlatform,
+ NotifyManager = existing.NotifyManager,
+ TimedOnShelfAt = timedOnShelfAt,
+ CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
+ GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
+ Description = request.Description?.Trim(),
+ EnableDineIn = existing.EnableDineIn,
+ EnablePickup = existing.EnablePickup,
+ EnableDelivery = existing.EnableDelivery,
+ IsFeatured = existing.IsFeatured
+ }, cancellationToken);
+ }
+
+ if (saved is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ }
+
+ var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(saved, categoryNameLookup));
+ }
+
+ ///
+ /// 删除商品。
+ ///
+ [HttpPost("delete")]
+ [ProducesResponseType(typeof(ApiResponse