feat(marketing): implement flash sale module api and app layer
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m4s

This commit is contained in:
2026-03-02 11:08:14 +08:00
parent 5a6da9be0c
commit 0f3542f33f
30 changed files with 2939 additions and 0 deletions

View File

@@ -0,0 +1,643 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 限时折扣列表查询请求。
/// </summary>
public sealed class FlashSaleListRequest
{
/// <summary>
/// 门店 ID可空空表示全部门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 活动名称关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 展示状态筛选ongoing/upcoming/ended
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 4;
}
/// <summary>
/// 限时折扣详情请求。
/// </summary>
public sealed class FlashSaleDetailRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 活动 ID。
/// </summary>
public string ActivityId { get; set; } = string.Empty;
}
/// <summary>
/// 保存限时折扣请求。
/// </summary>
public sealed class SaveFlashSaleRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 活动 ID编辑时传
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 活动周期once/recurring
/// </summary>
public string CycleType { get; set; } = "once";
/// <summary>
/// 周期日期模式fixed/long_term
/// </summary>
public string RecurringDateMode { get; set; } = "fixed";
/// <summary>
/// 活动开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 活动结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 每日开始时间HH:mm
/// </summary>
public string? TimeStart { get; set; }
/// <summary>
/// 每日结束时间HH:mm
/// </summary>
public string? TimeEnd { get; set; }
/// <summary>
/// 循环星期1-7周一到周日
/// </summary>
public List<int>? WeekDays { get; set; }
/// <summary>
/// 适用渠道delivery/pickup/dine_in
/// </summary>
public List<string>? Channels { get; set; }
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 活动门店 ID。
/// </summary>
public List<string>? StoreIds { get; set; }
/// <summary>
/// 折扣商品列表。
/// </summary>
public List<FlashSaleSaveProductRequest> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public FlashSaleMetricsRequest? Metrics { get; set; }
}
/// <summary>
/// 修改限时折扣状态请求。
/// </summary>
public sealed class ChangeFlashSaleStatusRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 活动 ID。
/// </summary>
public string ActivityId { get; set; } = string.Empty;
/// <summary>
/// 状态active/completed
/// </summary>
public string Status { get; set; } = "completed";
}
/// <summary>
/// 删除限时折扣请求。
/// </summary>
public sealed class DeleteFlashSaleRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 活动 ID。
/// </summary>
public string ActivityId { get; set; } = string.Empty;
}
/// <summary>
/// 限时折扣列表响应。
/// </summary>
public sealed class FlashSaleListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<FlashSaleListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总条数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 统计数据。
/// </summary>
public FlashSaleStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 限时折扣列表项响应。
/// </summary>
public sealed class FlashSaleListItemResponse
{
/// <summary>
/// 活动 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 活动周期once/recurring
/// </summary>
public string CycleType { get; set; } = "once";
/// <summary>
/// 周期日期模式fixed/long_term
/// </summary>
public string RecurringDateMode { get; set; } = "fixed";
/// <summary>
/// 活动开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 活动结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 每日开始时间HH:mm
/// </summary>
public string? TimeStart { get; set; }
/// <summary>
/// 每日结束时间HH:mm
/// </summary>
public string? TimeEnd { get; set; }
/// <summary>
/// 循环星期1-7
/// </summary>
public List<int> WeekDays { get; set; } = [];
/// <summary>
/// 编辑状态active/completed
/// </summary>
public string Status { get; set; } = "active";
/// <summary>
/// 展示状态ongoing/upcoming/ended
/// </summary>
public string DisplayStatus { get; set; } = "ongoing";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; set; }
/// <summary>
/// 适用渠道。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 活动门店。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 折扣商品。
/// </summary>
public List<FlashSaleProductResponse> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public FlashSaleMetricsResponse Metrics { get; set; } = new();
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 限时折扣详情响应。
/// </summary>
public sealed class FlashSaleDetailResponse
{
/// <summary>
/// 活动 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 活动周期once/recurring
/// </summary>
public string CycleType { get; set; } = "once";
/// <summary>
/// 周期日期模式fixed/long_term
/// </summary>
public string RecurringDateMode { get; set; } = "fixed";
/// <summary>
/// 活动开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 活动结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 每日开始时间HH:mm
/// </summary>
public string? TimeStart { get; set; }
/// <summary>
/// 每日结束时间HH:mm
/// </summary>
public string? TimeEnd { get; set; }
/// <summary>
/// 循环星期1-7
/// </summary>
public List<int> WeekDays { get; set; } = [];
/// <summary>
/// 编辑状态active/completed
/// </summary>
public string Status { get; set; } = "active";
/// <summary>
/// 展示状态ongoing/upcoming/ended
/// </summary>
public string DisplayStatus { get; set; } = "ongoing";
/// <summary>
/// 适用渠道。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 活动门店。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 折扣商品。
/// </summary>
public List<FlashSaleProductResponse> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public FlashSaleMetricsResponse Metrics { get; set; } = new();
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 限时折扣统计响应。
/// </summary>
public sealed class FlashSaleStatsResponse
{
/// <summary>
/// 活动总数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; set; }
/// <summary>
/// 参与商品数。
/// </summary>
public int ParticipatingProductCount { get; set; }
/// <summary>
/// 本月折扣销量。
/// </summary>
public int MonthlyDiscountSalesCount { get; set; }
}
/// <summary>
/// 限时折扣商品请求。
/// </summary>
public sealed class FlashSaleSaveProductRequest
{
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 折扣价。
/// </summary>
public decimal DiscountPrice { get; set; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
}
/// <summary>
/// 限时折扣商品响应。
/// </summary>
public sealed class FlashSaleProductResponse
{
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; set; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 商品状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
/// <summary>
/// 原价。
/// </summary>
public decimal OriginalPrice { get; set; }
/// <summary>
/// 折扣价。
/// </summary>
public decimal DiscountPrice { get; set; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
}
/// <summary>
/// 限时折扣指标请求。
/// </summary>
public sealed class FlashSaleMetricsRequest
{
/// <summary>
/// 活动销量(单)。
/// </summary>
public int ActivitySalesCount { get; set; }
/// <summary>
/// 折扣总额。
/// </summary>
public decimal DiscountTotalAmount { get; set; }
/// <summary>
/// 已循环周数。
/// </summary>
public int LoopedWeeks { get; set; }
/// <summary>
/// 本月折扣销量(单)。
/// </summary>
public int MonthlyDiscountSalesCount { get; set; }
}
/// <summary>
/// 限时折扣指标响应。
/// </summary>
public sealed class FlashSaleMetricsResponse
{
/// <summary>
/// 活动销量(单)。
/// </summary>
public int ActivitySalesCount { get; set; }
/// <summary>
/// 折扣总额。
/// </summary>
public decimal DiscountTotalAmount { get; set; }
/// <summary>
/// 已循环周数。
/// </summary>
public int LoopedWeeks { get; set; }
/// <summary>
/// 本月折扣销量(单)。
/// </summary>
public int MonthlyDiscountSalesCount { get; set; }
}
/// <summary>
/// 限时折扣商品分类选择器请求。
/// </summary>
public sealed class FlashSalePickerCategoriesRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 限时折扣商品分类选择器响应项。
/// </summary>
public sealed class FlashSalePickerCategoryItemResponse
{
/// <summary>
/// 分类 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 商品数量。
/// </summary>
public int ProductCount { get; set; }
}
/// <summary>
/// 限时折扣商品选择器请求。
/// </summary>
public sealed class FlashSalePickerProductsRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID可空
/// </summary>
public string? CategoryId { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 数量上限。
/// </summary>
public int? Limit { get; set; }
}
/// <summary>
/// 限时折扣商品选择器响应项。
/// </summary>
public sealed class FlashSalePickerProductItemResponse
{
/// <summary>
/// 商品 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; set; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 售价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 库存。
/// </summary>
public int Stock { get; set; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
}

View File

@@ -0,0 +1,427 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
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.Marketing;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 营销中心限时折扣活动管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/marketing/flash-sale")]
public sealed class MarketingFlashSaleController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 获取限时折扣活动列表。
/// </summary>
[HttpGet("list")]
[ProducesResponseType(typeof(ApiResponse<FlashSaleListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FlashSaleListResultResponse>> List(
[FromQuery] FlashSaleListRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetFlashSaleCampaignListQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
Status = request.Status,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<FlashSaleListResultResponse>.Ok(new FlashSaleListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize,
Stats = new FlashSaleStatsResponse
{
TotalCount = result.Stats.TotalCount,
OngoingCount = result.Stats.OngoingCount,
ParticipatingProductCount = result.Stats.ParticipatingProductCount,
MonthlyDiscountSalesCount = result.Stats.MonthlyDiscountSalesCount
}
});
}
/// <summary>
/// 获取限时折扣活动详情。
/// </summary>
[HttpGet("detail")]
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FlashSaleDetailResponse>> Detail(
[FromQuery] FlashSaleDetailRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
var result = await mediator.Send(new GetFlashSaleCampaignDetailQuery
{
OperationStoreId = operationStoreId,
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
}, cancellationToken);
if (result is null)
{
return ApiResponse<FlashSaleDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
}
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存限时折扣活动(新增/编辑)。
/// </summary>
[HttpPost("save")]
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FlashSaleDetailResponse>> Save(
[FromBody] SaveFlashSaleRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
var resolvedStoreIds = await ResolveStoreIdsForSaveAsync(
request.StoreIds,
operationStoreId,
cancellationToken);
var result = await mediator.Send(new SaveFlashSaleCampaignCommand
{
OperationStoreId = operationStoreId,
CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
Name = request.Name,
CycleType = request.CycleType,
RecurringDateMode = request.RecurringDateMode,
StartDate = ParseDateOnlyOrNull(request.StartDate, nameof(request.StartDate)),
EndDate = ParseDateOnlyOrNull(request.EndDate, nameof(request.EndDate)),
TimeStart = ParseTimeOrNull(request.TimeStart, nameof(request.TimeStart)),
TimeEnd = ParseTimeOrNull(request.TimeEnd, nameof(request.TimeEnd)),
WeekDays = request.WeekDays ?? [],
Channels = request.Channels ?? [],
PerUserLimit = request.PerUserLimit,
StoreIds = resolvedStoreIds,
Products = request.Products.Select(item => new FlashSaleSaveProductInputDto
{
ProductId = StoreApiHelpers.ParseRequiredSnowflake(item.ProductId, nameof(item.ProductId)),
DiscountPrice = item.DiscountPrice,
PerUserLimit = item.PerUserLimit
}).ToList(),
Metrics = request.Metrics is null
? null
: new FlashSaleMetricsDto
{
ActivitySalesCount = request.Metrics.ActivitySalesCount,
DiscountTotalAmount = request.Metrics.DiscountTotalAmount,
LoopedWeeks = request.Metrics.LoopedWeeks,
MonthlyDiscountSalesCount = request.Metrics.MonthlyDiscountSalesCount
}
}, cancellationToken);
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 修改限时折扣活动状态。
/// </summary>
[HttpPost("status")]
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FlashSaleDetailResponse>> ChangeStatus(
[FromBody] ChangeFlashSaleStatusRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
var result = await mediator.Send(new ChangeFlashSaleCampaignStatusCommand
{
OperationStoreId = operationStoreId,
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 删除限时折扣活动。
/// </summary>
[HttpPost("delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteFlashSaleRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
await mediator.Send(new DeleteFlashSaleCampaignCommand
{
OperationStoreId = operationStoreId,
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取限时折扣选品分类。
/// </summary>
[HttpGet("picker/categories")]
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerCategoryItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<FlashSalePickerCategoryItemResponse>>> PickerCategories(
[FromQuery] FlashSalePickerCategoriesRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
var result = await mediator.Send(new GetFlashSalePickerCategoriesQuery
{
OperationStoreId = operationStoreId
}, cancellationToken);
return ApiResponse<List<FlashSalePickerCategoryItemResponse>>.Ok(result
.Select(item => new FlashSalePickerCategoryItemResponse
{
Id = item.Id.ToString(),
Name = item.Name,
ProductCount = item.ProductCount
})
.ToList());
}
/// <summary>
/// 获取限时折扣选品商品。
/// </summary>
[HttpGet("picker/products")]
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerProductItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<FlashSalePickerProductItemResponse>>> PickerProducts(
[FromQuery] FlashSalePickerProductsRequest request,
CancellationToken cancellationToken)
{
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
var result = await mediator.Send(new GetFlashSalePickerProductsQuery
{
OperationStoreId = operationStoreId,
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
Keyword = request.Keyword,
Limit = request.Limit
}, cancellationToken);
return ApiResponse<List<FlashSalePickerProductItemResponse>>.Ok(result
.Select(item => new FlashSalePickerProductItemResponse
{
Id = item.Id.ToString(),
CategoryId = item.CategoryId.ToString(),
CategoryName = item.CategoryName,
Name = item.Name,
Price = item.Price,
Stock = item.Stock,
SpuCode = item.SpuCode,
Status = item.Status
})
.ToList());
}
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
string? storeId,
CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (!string.IsNullOrWhiteSpace(storeId))
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
return [parsedStoreId];
}
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
.Select(item => item.Id)
.OrderBy(item => item)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
private async Task<IReadOnlyCollection<long>> ResolveStoreIdsForSaveAsync(
IEnumerable<string>? storeIds,
long operationStoreId,
CancellationToken cancellationToken)
{
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
if (parsedStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
dbContext,
tenantId,
merchantId,
parsedStoreIds,
cancellationToken);
if (accessibleStoreIds.Count != parsedStoreIds.Count)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
}
if (!accessibleStoreIds.Contains(operationStoreId))
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店");
}
return accessibleStoreIds.OrderBy(item => item).ToList();
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime? ParseDateOnlyOrNull(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd");
}
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
}
private static TimeSpan? ParseTimeOrNull(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return StoreApiHelpers.ParseRequiredTime(value, fieldName);
}
private static FlashSaleListItemResponse MapListItem(FlashSaleListItemDto source)
{
return new FlashSaleListItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CycleType = source.Rules.CycleType,
RecurringDateMode = source.Rules.RecurringDateMode,
StartDate = ToDateOnly(source.Rules.StartDate),
EndDate = ToDateOnly(source.Rules.EndDate),
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
WeekDays = source.Rules.WeekDays.ToList(),
Status = source.Status,
DisplayStatus = source.DisplayStatus,
IsDimmed = source.IsDimmed,
Channels = source.Rules.Channels.ToList(),
PerUserLimit = source.Rules.PerUserLimit,
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
Products = source.Rules.Products.Select(MapProduct).ToList(),
Metrics = MapMetrics(source.Rules.Metrics),
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static FlashSaleDetailResponse MapDetail(FlashSaleDetailDto source)
{
return new FlashSaleDetailResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CycleType = source.Rules.CycleType,
RecurringDateMode = source.Rules.RecurringDateMode,
StartDate = ToDateOnly(source.Rules.StartDate),
EndDate = ToDateOnly(source.Rules.EndDate),
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
WeekDays = source.Rules.WeekDays.ToList(),
Status = source.Status,
DisplayStatus = source.DisplayStatus,
Channels = source.Rules.Channels.ToList(),
PerUserLimit = source.Rules.PerUserLimit,
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
Products = source.Rules.Products.Select(MapProduct).ToList(),
Metrics = MapMetrics(source.Rules.Metrics),
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static FlashSaleProductResponse MapProduct(FlashSaleProductRuleDto source)
{
return new FlashSaleProductResponse
{
ProductId = source.ProductId.ToString(),
CategoryId = source.CategoryId.ToString(),
CategoryName = source.CategoryName,
Name = source.Name,
SpuCode = source.SpuCode,
Status = source.Status,
OriginalPrice = source.OriginalPrice,
DiscountPrice = source.DiscountPrice,
PerUserLimit = source.PerUserLimit,
SoldCount = source.SoldCount
};
}
private static FlashSaleMetricsResponse MapMetrics(FlashSaleMetricsDto source)
{
return new FlashSaleMetricsResponse
{
ActivitySalesCount = source.ActivitySalesCount,
DiscountTotalAmount = source.DiscountTotalAmount,
LoopedWeeks = source.LoopedWeeks,
MonthlyDiscountSalesCount = source.MonthlyDiscountSalesCount
};
}
private static string? ToDateOnly(DateTime? value)
{
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
/// <summary>
/// 修改限时折扣活动状态命令。
/// </summary>
public sealed class ChangeFlashSaleCampaignStatusCommand : IRequest<FlashSaleDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
/// <summary>
/// 状态active/completed
/// </summary>
public string Status { get; init; } = "completed";
}

View File

@@ -0,0 +1,20 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
/// <summary>
/// 删除限时折扣活动命令。
/// </summary>
public sealed class DeleteFlashSaleCampaignCommand : IRequest<Unit>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,86 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
/// <summary>
/// 保存限时折扣活动命令。
/// </summary>
public sealed class SaveFlashSaleCampaignCommand : IRequest<FlashSaleDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID编辑时传
/// </summary>
public long? CampaignId { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动周期once/recurring
/// </summary>
public string CycleType { get; init; } = "once";
/// <summary>
/// 周期日期模式fixed/long_term
/// </summary>
public string RecurringDateMode { get; init; } = "fixed";
/// <summary>
/// 活动开始日期UTC 日期)。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 活动结束日期UTC 日期)。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 每日开始时间。
/// </summary>
public TimeSpan? TimeStart { get; init; }
/// <summary>
/// 每日结束时间。
/// </summary>
public TimeSpan? TimeEnd { get; init; }
/// <summary>
/// 循环星期1-7
/// </summary>
public IReadOnlyCollection<int> WeekDays { get; init; } = [];
/// <summary>
/// 适用渠道。
/// </summary>
public IReadOnlyCollection<string> Channels { get; init; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 活动门店。
/// </summary>
public IReadOnlyCollection<long> StoreIds { get; init; } = [];
/// <summary>
/// 商品配置输入。
/// </summary>
public IReadOnlyCollection<FlashSaleSaveProductInputDto> Products { get; init; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public FlashSaleMetricsDto? Metrics { get; init; }
}

View File

@@ -0,0 +1,38 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动详情 DTO。
/// </summary>
public sealed class FlashSaleDetailDto
{
/// <summary>
/// 活动 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 编辑状态active/completed
/// </summary>
public string Status { get; init; } = "active";
/// <summary>
/// 展示状态ongoing/upcoming/ended
/// </summary>
public string DisplayStatus { get; init; } = "ongoing";
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
/// <summary>
/// 活动规则。
/// </summary>
public FlashSaleRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,43 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动列表项 DTO。
/// </summary>
public sealed class FlashSaleListItemDto
{
/// <summary>
/// 活动 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 编辑状态active/completed
/// </summary>
public string Status { get; init; } = "active";
/// <summary>
/// 展示状态ongoing/upcoming/ended
/// </summary>
public string DisplayStatus { get; init; } = "ongoing";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
/// <summary>
/// 活动规则。
/// </summary>
public FlashSaleRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,33 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动列表结果 DTO。
/// </summary>
public sealed class FlashSaleListResultDto
{
/// <summary>
/// 列表数据。
/// </summary>
public IReadOnlyList<FlashSaleListItemDto> Items { get; init; } = [];
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 统计信息。
/// </summary>
public FlashSaleStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,28 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动指标 DTO。
/// </summary>
public sealed class FlashSaleMetricsDto
{
/// <summary>
/// 活动销量(单)。
/// </summary>
public int ActivitySalesCount { get; init; }
/// <summary>
/// 折扣总额。
/// </summary>
public decimal DiscountTotalAmount { get; init; }
/// <summary>
/// 已循环周数。
/// </summary>
public int LoopedWeeks { get; init; }
/// <summary>
/// 本月折扣销量(单)。
/// </summary>
public int MonthlyDiscountSalesCount { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣选品分类项 DTO。
/// </summary>
public sealed class FlashSalePickerCategoryItemDto
{
/// <summary>
/// 分类 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 商品数量。
/// </summary>
public int ProductCount { get; init; }
}

View File

@@ -0,0 +1,48 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣选品商品项 DTO。
/// </summary>
public sealed class FlashSalePickerProductItemDto
{
/// <summary>
/// 商品 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; init; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 售价。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 库存。
/// </summary>
public int Stock { get; init; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; init; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; init; } = "off_shelf";
}

View File

@@ -0,0 +1,58 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣商品规则 DTO。
/// </summary>
public sealed class FlashSaleProductRuleDto
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; init; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; init; } = string.Empty;
/// <summary>
/// 商品状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; init; } = "off_shelf";
/// <summary>
/// 原价。
/// </summary>
public decimal OriginalPrice { get; init; }
/// <summary>
/// 折扣价。
/// </summary>
public decimal DiscountPrice { get; init; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动规则 DTO。
/// </summary>
public sealed class FlashSaleRulesDto
{
/// <summary>
/// 活动周期once/recurring
/// </summary>
public string CycleType { get; init; } = "once";
/// <summary>
/// 周期日期模式fixed/long_term
/// </summary>
public string RecurringDateMode { get; init; } = "fixed";
/// <summary>
/// 活动开始日期UTC 日期)。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 活动结束日期UTC 日期)。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 每日开始时间。
/// </summary>
public TimeSpan? TimeStart { get; init; }
/// <summary>
/// 每日结束时间。
/// </summary>
public TimeSpan? TimeEnd { get; init; }
/// <summary>
/// 循环星期1-7周一到周日
/// </summary>
public IReadOnlyList<int> WeekDays { get; init; } = [];
/// <summary>
/// 适用渠道delivery/pickup/dine_in
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 活动门店 ID。
/// </summary>
public IReadOnlyList<long> StoreIds { get; init; } = [];
/// <summary>
/// 折扣商品。
/// </summary>
public IReadOnlyList<FlashSaleProductRuleDto> Products { get; init; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public FlashSaleMetricsDto Metrics { get; init; } = new();
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣保存商品输入 DTO。
/// </summary>
public sealed class FlashSaleSaveProductInputDto
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 折扣价。
/// </summary>
public decimal DiscountPrice { get; init; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
/// <summary>
/// 限时折扣活动统计 DTO。
/// </summary>
public sealed class FlashSaleStatsDto
{
/// <summary>
/// 活动总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; init; }
/// <summary>
/// 参与商品数。
/// </summary>
public int ParticipatingProductCount { get; init; }
/// <summary>
/// 本月折扣销量(单)。
/// </summary>
public int MonthlyDiscountSalesCount { get; init; }
}

View File

@@ -0,0 +1,102 @@
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale;
/// <summary>
/// 限时折扣 DTO 映射工厂。
/// </summary>
internal static class FlashSaleDtoFactory
{
/// <summary>
/// 构建列表项 DTO。
/// </summary>
public static FlashSaleListItemDto ToListItemDto(
PromotionCampaign campaign,
FlashSaleRulesDto rules,
DateTime nowUtc)
{
var displayStatus = FlashSaleMapping.ResolveDisplayStatus(campaign, rules, nowUtc);
return new FlashSaleListItemDto
{
Id = campaign.Id,
Name = campaign.Name,
Status = FlashSaleMapping.ToStatusText(campaign.Status),
DisplayStatus = displayStatus,
IsDimmed = FlashSaleMapping.IsDimmed(displayStatus),
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
Rules = rules
};
}
/// <summary>
/// 构建详情 DTO。
/// </summary>
public static FlashSaleDetailDto ToDetailDto(
PromotionCampaign campaign,
FlashSaleRulesDto rules,
DateTime nowUtc)
{
return new FlashSaleDetailDto
{
Id = campaign.Id,
Name = campaign.Name,
Status = FlashSaleMapping.ToStatusText(campaign.Status),
DisplayStatus = FlashSaleMapping.ResolveDisplayStatus(campaign, rules, nowUtc),
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
Rules = rules
};
}
/// <summary>
/// 构建统计 DTO。
/// </summary>
public static FlashSaleStatsDto ToStatsDto(IReadOnlyCollection<FlashSaleListItemDto> items)
{
if (items.Count == 0)
{
return new FlashSaleStatsDto();
}
var participatingProductCount = items
.SelectMany(item => item.Rules.Products)
.Select(item => item.ProductId)
.Distinct()
.Count();
var monthlyDiscountSalesCount = items.Sum(item => item.Rules.Metrics.MonthlyDiscountSalesCount);
return new FlashSaleStatsDto
{
TotalCount = items.Count,
OngoingCount = items.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)),
ParticipatingProductCount = participatingProductCount,
MonthlyDiscountSalesCount = monthlyDiscountSalesCount
};
}
/// <summary>
/// 构建默认新增活动实体。
/// </summary>
public static PromotionCampaign CreateNewCampaign(
string name,
DateTime startAt,
DateTime endAt,
string rulesJson)
{
return new PromotionCampaign
{
Name = name,
PromotionType = PromotionType.FlashSale,
Status = PromotionStatus.Active,
StartAt = startAt,
EndAt = endAt,
RulesJson = rulesJson,
AudienceDescription = null,
Budget = null,
BannerUrl = null
};
}
}

View File

@@ -0,0 +1,581 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale;
/// <summary>
/// 限时折扣映射与规则校验辅助。
/// </summary>
internal static class FlashSaleMapping
{
private const string CycleTypeOnce = "once";
private const string CycleTypeRecurring = "recurring";
private const string RecurringDateModeFixed = "fixed";
private const string RecurringDateModeLongTerm = "long_term";
private const string DisplayStatusEnded = "ended";
private const string DisplayStatusOngoing = "ongoing";
private const string DisplayStatusUpcoming = "upcoming";
private static readonly HashSet<string> AllowedChannels =
[
"delivery",
"pickup",
"dine_in"
];
private static readonly HashSet<string> AllowedDisplayStatuses =
[
DisplayStatusOngoing,
DisplayStatusUpcoming,
DisplayStatusEnded
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// 校验并标准化展示状态筛选。
/// </summary>
public static bool TryNormalizeDisplayStatusFilter(string? value, out string? normalized)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(candidate))
{
normalized = null;
return true;
}
if (AllowedDisplayStatuses.Contains(candidate))
{
normalized = candidate;
return true;
}
normalized = null;
return false;
}
/// <summary>
/// 解析状态文本为领域状态。
/// </summary>
public static PromotionStatus ParseStatus(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
return candidate switch
{
"active" => PromotionStatus.Active,
"completed" => PromotionStatus.Completed,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
/// <summary>
/// 输出状态文本。
/// </summary>
public static string ToStatusText(PromotionStatus status)
{
return status switch
{
PromotionStatus.Active => "active",
PromotionStatus.Completed => "completed",
_ => "completed"
};
}
/// <summary>
/// 商品状态输出文本。
/// </summary>
public static string ToProductStatusText(ProductStatus status, ProductSoldoutMode? soldoutMode)
{
if (soldoutMode.HasValue)
{
return "sold_out";
}
return status switch
{
ProductStatus.OnSale => "on_sale",
_ => "off_shelf"
};
}
/// <summary>
/// 将开始时间规范为当天 00:00:00UTC
/// </summary>
public static DateTime NormalizeStartOfDay(DateTime dateTime)
{
var utc = dateTime.Kind == DateTimeKind.Utc
? dateTime
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
return utc.Date;
}
/// <summary>
/// 将结束时间规范为当天 23:59:59.9999999UTC
/// </summary>
public static DateTime NormalizeEndOfDay(DateTime dateTime)
{
var utc = dateTime.Kind == DateTimeKind.Utc
? dateTime
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
return utc.Date.AddDays(1).AddTicks(-1);
}
/// <summary>
/// 解析活动规则。
/// </summary>
public static FlashSaleRulesDto DeserializeRules(string? rulesJson, long campaignId)
{
if (string.IsNullOrWhiteSpace(rulesJson))
{
throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失");
}
var payload = JsonSerializer.Deserialize<FlashSaleRulesDto>(rulesJson, JsonOptions)
?? throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则格式错误");
return NormalizeRulesForSave(
payload.CycleType,
payload.RecurringDateMode,
payload.StartDate,
payload.EndDate,
payload.TimeStart,
payload.TimeEnd,
payload.WeekDays,
payload.Channels,
payload.PerUserLimit,
payload.StoreIds,
payload.Products,
payload.Metrics,
null);
}
/// <summary>
/// 序列化活动规则。
/// </summary>
public static string SerializeRules(FlashSaleRulesDto rules)
{
return JsonSerializer.Serialize(rules, JsonOptions);
}
/// <summary>
/// 标准化并校验保存规则。
/// </summary>
public static FlashSaleRulesDto NormalizeRulesForSave(
string? cycleType,
string? recurringDateMode,
DateTime? startDate,
DateTime? endDate,
TimeSpan? timeStart,
TimeSpan? timeEnd,
IReadOnlyCollection<int>? weekDays,
IReadOnlyCollection<string>? channels,
int? perUserLimit,
IReadOnlyCollection<long>? storeIds,
IReadOnlyCollection<FlashSaleProductRuleDto>? products,
FlashSaleMetricsDto? metrics,
FlashSaleMetricsDto? fallbackMetrics)
{
var normalizedCycleType = NormalizeCycleType(cycleType);
var normalizedRecurringDateMode = NormalizeRecurringDateMode(recurringDateMode, normalizedCycleType);
var (normalizedStartDate, normalizedEndDate) = NormalizeDateRange(
normalizedCycleType,
normalizedRecurringDateMode,
startDate,
endDate);
var (normalizedTimeStart, normalizedTimeEnd) = NormalizeTimeRange(timeStart, timeEnd);
var normalizedWeekDays = NormalizeWeekDays(normalizedCycleType, weekDays);
var normalizedChannels = NormalizeChannels(channels);
var normalizedPerUserLimit = NormalizeOptionalLimit(perUserLimit, "perUserLimit 参数不合法");
var normalizedStoreIds = NormalizeStoreIds(storeIds);
var normalizedProducts = NormalizeProducts(products, normalizedPerUserLimit);
var normalizedMetrics = NormalizeMetrics(metrics ?? fallbackMetrics);
return new FlashSaleRulesDto
{
CycleType = normalizedCycleType,
RecurringDateMode = normalizedRecurringDateMode,
StartDate = normalizedStartDate,
EndDate = normalizedEndDate,
TimeStart = normalizedTimeStart,
TimeEnd = normalizedTimeEnd,
WeekDays = normalizedWeekDays,
Channels = normalizedChannels,
PerUserLimit = normalizedPerUserLimit,
StoreIds = normalizedStoreIds,
Products = normalizedProducts,
Metrics = normalizedMetrics
};
}
/// <summary>
/// 判断活动是否对可见门店可见。
/// </summary>
public static bool HasVisibleStore(FlashSaleRulesDto rules, IReadOnlyCollection<long> visibleStoreIds)
{
if (visibleStoreIds.Count == 0)
{
return false;
}
return rules.StoreIds.Any(visibleStoreIds.Contains);
}
/// <summary>
/// 解析活动展示状态。
/// </summary>
public static string ResolveDisplayStatus(
PromotionCampaign campaign,
FlashSaleRulesDto rules,
DateTime nowUtc)
{
if (campaign.Status is PromotionStatus.Completed or PromotionStatus.Paused)
{
return DisplayStatusEnded;
}
if (string.Equals(rules.CycleType, CycleTypeRecurring, StringComparison.Ordinal) &&
string.Equals(rules.RecurringDateMode, RecurringDateModeLongTerm, StringComparison.Ordinal))
{
return DisplayStatusOngoing;
}
if (nowUtc < campaign.StartAt)
{
return DisplayStatusUpcoming;
}
if (nowUtc > campaign.EndAt)
{
return DisplayStatusEnded;
}
return DisplayStatusOngoing;
}
/// <summary>
/// 判断是否弱化展示。
/// </summary>
public static bool IsDimmed(string displayStatus)
{
return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
}
/// <summary>
/// 计算活动实体起止时间窗口。
/// </summary>
public static (DateTime StartAt, DateTime EndAt) ResolveCampaignWindow(FlashSaleRulesDto rules, DateTime nowUtc)
{
if (string.Equals(rules.CycleType, CycleTypeRecurring, StringComparison.Ordinal) &&
string.Equals(rules.RecurringDateMode, RecurringDateModeLongTerm, StringComparison.Ordinal) &&
(!rules.StartDate.HasValue || !rules.EndDate.HasValue))
{
var defaultStart = NormalizeStartOfDay(nowUtc);
var defaultEnd = NormalizeEndOfDay(defaultStart.AddYears(20));
return (defaultStart, defaultEnd);
}
if (!rules.StartDate.HasValue || !rules.EndDate.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动日期范围缺失");
}
return (NormalizeStartOfDay(rules.StartDate.Value), NormalizeEndOfDay(rules.EndDate.Value));
}
private static string NormalizeCycleType(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (candidate is not (CycleTypeOnce or CycleTypeRecurring))
{
throw new BusinessException(ErrorCodes.BadRequest, "cycleType 参数不合法");
}
return candidate;
}
private static string NormalizeRecurringDateMode(string? value, string cycleType)
{
if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal))
{
return RecurringDateModeFixed;
}
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (candidate is not (RecurringDateModeFixed or RecurringDateModeLongTerm))
{
throw new BusinessException(ErrorCodes.BadRequest, "recurringDateMode 参数不合法");
}
return candidate;
}
private static (DateTime? StartDate, DateTime? EndDate) NormalizeDateRange(
string cycleType,
string recurringDateMode,
DateTime? startDate,
DateTime? endDate)
{
var normalizedStart = startDate.HasValue ? NormalizeStartOfDay(startDate.Value) : (DateTime?)null;
var normalizedEnd = endDate.HasValue ? NormalizeStartOfDay(endDate.Value) : (DateTime?)null;
if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal) ||
string.Equals(recurringDateMode, RecurringDateModeFixed, StringComparison.Ordinal))
{
if (!normalizedStart.HasValue || !normalizedEnd.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "startDate 与 endDate 不能为空");
}
if (normalizedStart.Value > normalizedEnd.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动开始日期不能晚于结束日期");
}
return (normalizedStart, normalizedEnd);
}
if (normalizedStart.HasValue != normalizedEnd.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "startDate 与 endDate 必须同时传入");
}
if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart.Value > normalizedEnd.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动开始日期不能晚于结束日期");
}
return (normalizedStart, normalizedEnd);
}
private static (TimeSpan? TimeStart, TimeSpan? TimeEnd) NormalizeTimeRange(TimeSpan? timeStart, TimeSpan? timeEnd)
{
if (!timeStart.HasValue && !timeEnd.HasValue)
{
return (null, null);
}
if (!timeStart.HasValue || !timeEnd.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "timeStart 与 timeEnd 必须同时传入");
}
if (timeStart.Value >= timeEnd.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "每日开始时间必须早于结束时间");
}
return (timeStart.Value, timeEnd.Value);
}
private static IReadOnlyList<int> NormalizeWeekDays(string cycleType, IReadOnlyCollection<int>? weekDays)
{
if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal))
{
return [];
}
var normalized = (weekDays ?? Array.Empty<int>())
.Where(day => day is >= 1 and <= 7)
.Distinct()
.OrderBy(day => day)
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "weekDays 不能为空");
}
return normalized;
}
private static IReadOnlyList<string> NormalizeChannels(IReadOnlyCollection<string>? channels)
{
var normalized = (channels ?? Array.Empty<string>())
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
}
if (normalized.Any(item => !AllowedChannels.Contains(item)))
{
throw new BusinessException(ErrorCodes.BadRequest, "channels 存在非法值");
}
return normalized;
}
private static IReadOnlyList<long> NormalizeStoreIds(IReadOnlyCollection<long>? storeIds)
{
var normalized = (storeIds ?? Array.Empty<long>())
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
return normalized;
}
private static IReadOnlyList<FlashSaleProductRuleDto> NormalizeProducts(
IReadOnlyCollection<FlashSaleProductRuleDto>? products,
int? campaignPerUserLimit)
{
var normalized = (products ?? Array.Empty<FlashSaleProductRuleDto>())
.Select(item =>
{
if (item.ProductId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "productId 参数不合法");
}
if (item.CategoryId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "categoryId 参数不合法");
}
var normalizedName = (item.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
throw new BusinessException(ErrorCodes.BadRequest, "商品名称不能为空");
}
var normalizedSpuCode = (item.SpuCode ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedSpuCode))
{
throw new BusinessException(ErrorCodes.BadRequest, "商品 SPU 编码不能为空");
}
var normalizedStatus = NormalizeProductStatus(item.Status);
var normalizedOriginalPrice = NormalizeMoney(item.OriginalPrice, "商品原价必须大于 0");
var normalizedDiscountPrice = NormalizeMoney(item.DiscountPrice, "折扣价必须大于 0");
if (normalizedDiscountPrice > normalizedOriginalPrice)
{
throw new BusinessException(ErrorCodes.BadRequest, "折扣价不能高于原价");
}
var normalizedPerUserLimit = NormalizeOptionalLimit(item.PerUserLimit, "商品每人限购必须大于 0");
if (campaignPerUserLimit.HasValue &&
normalizedPerUserLimit.HasValue &&
normalizedPerUserLimit.Value > campaignPerUserLimit.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "商品每人限购不能大于活动每人限购");
}
return new FlashSaleProductRuleDto
{
ProductId = item.ProductId,
CategoryId = item.CategoryId,
CategoryName = (item.CategoryName ?? string.Empty).Trim(),
Name = normalizedName,
SpuCode = normalizedSpuCode,
Status = normalizedStatus,
OriginalPrice = normalizedOriginalPrice,
DiscountPrice = normalizedDiscountPrice,
PerUserLimit = normalizedPerUserLimit,
SoldCount = Math.Max(0, item.SoldCount)
};
})
.OrderBy(item => item.Name)
.ThenBy(item => item.ProductId)
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "products 不能为空");
}
var duplicateProductId = normalized
.GroupBy(item => item.ProductId)
.FirstOrDefault(group => group.Count() > 1)?
.Key;
if (duplicateProductId.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, $"商品重复: {duplicateProductId.Value}");
}
return normalized;
}
private static string NormalizeProductStatus(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
return candidate switch
{
"on_sale" => "on_sale",
"off_shelf" => "off_shelf",
"sold_out" => "sold_out",
_ => throw new BusinessException(ErrorCodes.BadRequest, "商品状态不合法")
};
}
private static int? NormalizeOptionalLimit(int? value, string errorMessage)
{
if (!value.HasValue)
{
return null;
}
if (value.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, errorMessage);
}
return value.Value;
}
private static FlashSaleMetricsDto NormalizeMetrics(FlashSaleMetricsDto? metrics)
{
if (metrics is null)
{
return new FlashSaleMetricsDto();
}
return new FlashSaleMetricsDto
{
ActivitySalesCount = Math.Max(0, metrics.ActivitySalesCount),
DiscountTotalAmount = NormalizeNonNegativeMoney(metrics.DiscountTotalAmount),
LoopedWeeks = Math.Max(0, metrics.LoopedWeeks),
MonthlyDiscountSalesCount = Math.Max(0, metrics.MonthlyDiscountSalesCount)
};
}
private static decimal NormalizeMoney(decimal value, string errorMessage)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, errorMessage);
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizeNonNegativeMoney(decimal value)
{
if (value <= 0)
{
return 0m;
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 修改限时折扣活动状态命令处理器。
/// </summary>
public sealed class ChangeFlashSaleCampaignStatusCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeFlashSaleCampaignStatusCommand, FlashSaleDetailDto>
{
/// <inheritdoc />
public async Task<FlashSaleDetailDto> Handle(ChangeFlashSaleCampaignStatusCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FlashSale,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
campaign.Status = FlashSaleMapping.ParseStatus(request.Status);
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FlashSaleDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 删除限时折扣活动命令处理器。
/// </summary>
public sealed class DeleteFlashSaleCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteFlashSaleCampaignCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(DeleteFlashSaleCampaignCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FlashSale,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
await promotionCampaignRepository.DeleteAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 限时折扣活动详情查询处理器。
/// </summary>
public sealed class GetFlashSaleCampaignDetailQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFlashSaleCampaignDetailQuery, FlashSaleDetailDto?>
{
/// <inheritdoc />
public async Task<FlashSaleDetailDto?> Handle(GetFlashSaleCampaignDetailQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FlashSale,
cancellationToken);
if (campaign is null)
{
return null;
}
var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
return FlashSaleDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,112 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 限时折扣活动列表查询处理器。
/// </summary>
public sealed class GetFlashSaleCampaignListQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFlashSaleCampaignListQuery, FlashSaleListResultDto>
{
/// <inheritdoc />
public async Task<FlashSaleListResultDto> Handle(GetFlashSaleCampaignListQuery request, CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
if (!FlashSaleMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
if (request.VisibleStoreIds.Count == 0)
{
return new FlashSaleListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = new FlashSaleStatsDto()
};
}
var tenantId = tenantProvider.GetCurrentTenantId();
var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(
tenantId,
PromotionType.FlashSale,
cancellationToken);
if (campaigns.Count == 0)
{
return new FlashSaleListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = new FlashSaleStatsDto()
};
}
var nowUtc = DateTime.UtcNow;
var visibleItems = new List<FlashSaleListItemDto>(campaigns.Count);
foreach (var campaign in campaigns)
{
var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!FlashSaleMapping.HasVisibleStore(rules, request.VisibleStoreIds))
{
continue;
}
visibleItems.Add(FlashSaleDtoFactory.ToListItemDto(campaign, rules, nowUtc));
}
var stats = FlashSaleDtoFactory.ToStatsDto(visibleItems);
IEnumerable<FlashSaleListItemDto> filtered = visibleItems;
if (!string.IsNullOrWhiteSpace(normalizedStatus))
{
filtered = filtered.Where(item =>
string.Equals(item.DisplayStatus, normalizedStatus, StringComparison.Ordinal));
}
var keyword = request.Keyword?.Trim();
if (!string.IsNullOrWhiteSpace(keyword))
{
filtered = filtered.Where(item =>
item.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
var ordered = filtered
.OrderByDescending(item => item.UpdatedAt)
.ThenByDescending(item => item.Id)
.ToList();
var total = ordered.Count;
var paged = ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return new FlashSaleListResultDto
{
Items = paged,
TotalCount = total,
Page = page,
PageSize = pageSize,
Stats = stats
};
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 限时折扣选品分类查询处理器。
/// </summary>
public sealed class GetFlashSalePickerCategoriesQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFlashSalePickerCategoriesQuery, IReadOnlyList<FlashSalePickerCategoryItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<FlashSalePickerCategoryItemDto>> Handle(
GetFlashSalePickerCategoriesQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var categories = await productRepository.GetCategoriesByStoreAsync(
tenantId,
request.OperationStoreId,
true,
cancellationToken);
if (categories.Count == 0)
{
return [];
}
var categoryIds = categories.Select(item => item.Id).ToList();
var productCountLookup = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.OperationStoreId,
categoryIds,
cancellationToken);
return categories
.Select(item => new FlashSalePickerCategoryItemDto
{
Id = item.Id,
Name = item.Name,
ProductCount = productCountLookup.GetValueOrDefault(item.Id, 0)
})
.ToList();
}
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 限时折扣选品商品查询处理器。
/// </summary>
public sealed class GetFlashSalePickerProductsQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFlashSalePickerProductsQuery, IReadOnlyList<FlashSalePickerProductItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<FlashSalePickerProductItemDto>> Handle(
GetFlashSalePickerProductsQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var limit = Math.Clamp(request.Limit ?? 200, 1, 500);
var products = await productRepository.SearchPickerAsync(
tenantId,
request.OperationStoreId,
request.CategoryId,
request.Keyword,
limit,
cancellationToken);
if (products.Count == 0)
{
return [];
}
var categoryLookup = (await productRepository.GetCategoriesByStoreAsync(
tenantId,
request.OperationStoreId,
false,
cancellationToken))
.ToDictionary(item => item.Id, item => item.Name);
return products
.Select(item => new FlashSalePickerProductItemDto
{
Id = item.Id,
CategoryId = item.CategoryId,
CategoryName = categoryLookup.GetValueOrDefault(item.CategoryId, string.Empty),
Name = item.Name,
Price = decimal.Round(item.Price, 2, MidpointRounding.AwayFromZero),
Stock = Math.Max(0, item.StockQuantity ?? 0),
SpuCode = item.SpuCode,
Status = FlashSaleMapping.ToProductStatusText(item.Status, item.SoldoutMode)
})
.ToList();
}
}

View File

@@ -0,0 +1,177 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers;
/// <summary>
/// 保存限时折扣活动命令处理器。
/// </summary>
public sealed class SaveFlashSaleCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveFlashSaleCampaignCommand, FlashSaleDetailDto>
{
/// <inheritdoc />
public async Task<FlashSaleDetailDto> Handle(SaveFlashSaleCampaignCommand request, CancellationToken cancellationToken)
{
var normalizedName = request.Name.Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
throw new BusinessException(ErrorCodes.BadRequest, "活动名称不能为空");
}
if (normalizedName.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动名称长度不能超过 64");
}
if (request.StoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
if (!request.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店");
}
var tenantId = tenantProvider.GetCurrentTenantId();
PromotionCampaign? campaign = null;
FlashSaleMetricsDto? fallbackMetrics = null;
var soldCountLookup = new Dictionary<long, int>();
if (request.CampaignId.HasValue)
{
campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId.Value,
tenantId,
PromotionType.FlashSale,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var existingRules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!existingRules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
fallbackMetrics = existingRules.Metrics;
soldCountLookup = existingRules.Products
.GroupBy(item => item.ProductId)
.ToDictionary(group => group.Key, group => Math.Max(0, group.First().SoldCount));
}
var saveProductItems = request.Products
.Where(item => item.ProductId > 0)
.GroupBy(item => item.ProductId)
.Select(group => group.First())
.ToList();
if (saveProductItems.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "折扣商品不能为空");
}
var productIds = saveProductItems.Select(item => item.ProductId).ToList();
var products = await productRepository.GetByIdsAsync(
tenantId,
request.OperationStoreId,
productIds,
cancellationToken);
if (products.Count != saveProductItems.Count)
{
throw new BusinessException(ErrorCodes.BadRequest, "存在无效商品,请刷新后重试");
}
var categories = await productRepository.GetCategoriesByStoreAsync(
tenantId,
request.OperationStoreId,
false,
cancellationToken);
var categoryNameLookup = categories.ToDictionary(item => item.Id, item => item.Name);
var productLookup = products.ToDictionary(item => item.Id, item => item);
var normalizedProducts = saveProductItems
.Select(item =>
{
var product = productLookup[item.ProductId];
var normalizedOriginalPrice = decimal.Round(product.Price, 2, MidpointRounding.AwayFromZero);
if (item.DiscountPrice > normalizedOriginalPrice)
{
throw new BusinessException(ErrorCodes.BadRequest, $"商品[{product.Name}]折扣价不能高于原价");
}
soldCountLookup.TryGetValue(item.ProductId, out var soldCount);
return new FlashSaleProductRuleDto
{
ProductId = product.Id,
CategoryId = product.CategoryId,
CategoryName = categoryNameLookup.GetValueOrDefault(product.CategoryId, string.Empty),
Name = product.Name,
SpuCode = product.SpuCode,
Status = FlashSaleMapping.ToProductStatusText(product.Status, product.SoldoutMode),
OriginalPrice = normalizedOriginalPrice,
DiscountPrice = item.DiscountPrice,
PerUserLimit = item.PerUserLimit,
SoldCount = soldCount
};
})
.ToList();
var normalizedRules = FlashSaleMapping.NormalizeRulesForSave(
request.CycleType,
request.RecurringDateMode,
request.StartDate,
request.EndDate,
request.TimeStart,
request.TimeEnd,
request.WeekDays,
request.Channels,
request.PerUserLimit,
request.StoreIds,
normalizedProducts,
request.Metrics,
fallbackMetrics);
var nowUtc = DateTime.UtcNow;
var campaignWindow = FlashSaleMapping.ResolveCampaignWindow(normalizedRules, nowUtc);
if (campaign is null)
{
campaign = FlashSaleDtoFactory.CreateNewCampaign(
normalizedName,
campaignWindow.StartAt,
campaignWindow.EndAt,
FlashSaleMapping.SerializeRules(normalizedRules));
await promotionCampaignRepository.AddAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FlashSaleDtoFactory.ToDetailDto(campaign, normalizedRules, nowUtc);
}
campaign.Name = normalizedName;
campaign.StartAt = campaignWindow.StartAt;
campaign.EndAt = campaignWindow.EndAt;
campaign.RulesJson = FlashSaleMapping.SerializeRules(normalizedRules);
if (campaign.Status != PromotionStatus.Completed)
{
campaign.Status = PromotionStatus.Active;
}
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FlashSaleDtoFactory.ToDetailDto(campaign, normalizedRules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,21 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
/// <summary>
/// 查询限时折扣活动详情。
/// </summary>
public sealed class GetFlashSaleCampaignDetailQuery : IRequest<FlashSaleDetailDto?>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
/// <summary>
/// 查询限时折扣活动列表。
/// </summary>
public sealed class GetFlashSaleCampaignListQuery : IRequest<FlashSaleListResultDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态筛选ongoing/upcoming/ended
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 4;
}

View File

@@ -0,0 +1,16 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
/// <summary>
/// 查询限时折扣选品分类列表。
/// </summary>
public sealed class GetFlashSalePickerCategoriesQuery : IRequest<IReadOnlyList<FlashSalePickerCategoryItemDto>>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
/// <summary>
/// 查询限时折扣选品商品列表。
/// </summary>
public sealed class GetFlashSalePickerProductsQuery : IRequest<IReadOnlyList<FlashSalePickerProductItemDto>>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 分类 ID可空
/// </summary>
public long? CategoryId { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 条数上限。
/// </summary>
public int? Limit { get; init; }
}

View File

@@ -140,6 +140,15 @@ public interface IProductRepository
/// </summary> /// </summary>
Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default); Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default);
/// <summary>
/// 按标识批量读取商品。
/// </summary>
Task<IReadOnlyList<Product>> GetByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> productIds,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 批量更新商品分类。 /// 批量更新商品分类。
/// </summary> /// </summary>

View File

@@ -510,6 +510,29 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
/// <inheritdoc />
public async Task<IReadOnlyList<Product>> GetByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> productIds,
CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return [];
}
return await context.Products
.AsNoTracking()
.Where(x =>
x.TenantId == tenantId &&
x.StoreId == storeId &&
productIds.Contains(x.Id))
.OrderBy(x => x.Name)
.ThenBy(x => x.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default) public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
{ {