From c9e2226b4889b2e2f3837c60eec2de9f6a3606eb Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 2 Mar 2026 13:08:56 +0800 Subject: [PATCH] feat(marketing): implement tenant seckill backend module --- TakeoutSaaS.Docs | 2 +- .../Contracts/Marketing/SeckillContracts.cs | 700 ++++++++++++++++++ .../Controllers/MarketingSeckillController.cs | 446 +++++++++++ .../ChangeSeckillCampaignStatusCommand.cs | 27 + .../Commands/DeleteSeckillCampaignCommand.cs | 21 + .../Commands/SaveSeckillCampaignCommand.cs | 90 +++ .../Coupons/Seckill/Dto/SeckillDetailDto.cs | 39 + .../Coupons/Seckill/Dto/SeckillListItemDto.cs | 44 ++ .../Seckill/Dto/SeckillListResultDto.cs | 34 + .../Coupons/Seckill/Dto/SeckillMetricsDto.cs | 27 + .../Dto/SeckillPickerCategoryItemDto.cs | 24 + .../Dto/SeckillPickerProductItemDto.cs | 49 ++ .../Seckill/Dto/SeckillProductRuleDto.cs | 62 ++ .../Coupons/Seckill/Dto/SeckillRulesDto.cs | 72 ++ .../Seckill/Dto/SeckillSaveProductInputDto.cs | 27 + .../Seckill/Dto/SeckillSessionRuleDto.cs | 17 + .../Coupons/Seckill/Dto/SeckillStatsDto.cs | 27 + ...angeSeckillCampaignStatusCommandHandler.cs | 45 ++ .../DeleteSeckillCampaignCommandHandler.cs | 42 ++ .../GetSeckillCampaignDetailQueryHandler.cs | 45 ++ .../GetSeckillCampaignListQueryHandler.cs | 113 +++ .../GetSeckillPickerCategoriesQueryHandler.cs | 51 ++ .../GetSeckillPickerProductsQueryHandler.cs | 61 ++ .../SaveSeckillCampaignCommandHandler.cs | 178 +++++ .../Queries/GetSeckillCampaignDetailQuery.cs | 22 + .../Queries/GetSeckillCampaignListQuery.cs | 37 + .../GetSeckillPickerCategoriesQuery.cs | 17 + .../Queries/GetSeckillPickerProductsQuery.cs | 32 + .../App/Coupons/Seckill/SeckillDtoFactory.cs | 105 +++ .../App/Coupons/Seckill/SeckillMapping.cs | 652 ++++++++++++++++ .../Coupons/Enums/PromotionType.cs | 7 +- 31 files changed, 3113 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/SeckillContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingSeckillController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/ChangeSeckillCampaignStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/DeleteSeckillCampaignCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/SaveSeckillCampaignCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListResultDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillMetricsDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerCategoryItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerProductItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillProductRuleDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillRulesDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSaveProductInputDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSessionRuleDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillStatsDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/ChangeSeckillCampaignStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/DeleteSeckillCampaignCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerCategoriesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerProductsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/SaveSeckillCampaignCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerCategoriesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerProductsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillDtoFactory.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillMapping.cs diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs index de7aefd..5da102c 160000 --- a/TakeoutSaaS.Docs +++ b/TakeoutSaaS.Docs @@ -1 +1 @@ -Subproject commit de7aefd0ffe5c3ab842207c62aaa736edf7b8dfb +Subproject commit 5da102c97c2cd7acbd3f20d69f286b3d0eadf0fe diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/SeckillContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/SeckillContracts.cs new file mode 100644 index 0000000..6c51785 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/SeckillContracts.cs @@ -0,0 +1,700 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Marketing; + +/// +/// 秒杀活动列表查询请求。 +/// +public sealed class SeckillListRequest +{ + /// + /// 门店 ID(可空,空表示全部门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 活动名称关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 展示状态筛选(ongoing/upcoming/ended)。 + /// + public string? Status { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 4; +} + +/// +/// 秒杀活动详情请求。 +/// +public sealed class SeckillDetailRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 保存秒杀活动请求。 +/// +public sealed class SaveSeckillRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID(编辑时传)。 + /// + public string? Id { get; set; } + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(timed/hourly)。 + /// + public string ActivityType { get; set; } = "timed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 整点秒杀场次。 + /// + public List? Sessions { get; set; } + + /// + /// 适用渠道(delivery/pickup/dine_in)。 + /// + public List? Channels { get; set; } + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 是否开启预热。 + /// + public bool PreheatEnabled { get; set; } + + /// + /// 预热小时数(空表示不启用)。 + /// + public int? PreheatHours { get; set; } + + /// + /// 活动门店 ID。 + /// + public List? StoreIds { get; set; } + + /// + /// 秒杀商品列表。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public SeckillMetricsRequest? Metrics { get; set; } +} + +/// +/// 修改秒杀活动状态请求。 +/// +public sealed class ChangeSeckillStatusRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; + + /// + /// 状态(active/completed)。 + /// + public string Status { get; set; } = "completed"; +} + +/// +/// 删除秒杀活动请求。 +/// +public sealed class DeleteSeckillRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 秒杀活动列表响应。 +/// +public sealed class SeckillListResultResponse +{ + /// + /// 列表项。 + /// + public List Items { get; set; } = []; + + /// + /// 总条数。 + /// + public int Total { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 统计数据。 + /// + public SeckillStatsResponse Stats { get; set; } = new(); +} + +/// +/// 秒杀活动列表项响应。 +/// +public sealed class SeckillListItemResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(timed/hourly)。 + /// + public string ActivityType { get; set; } = "timed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 整点秒杀场次。 + /// + public List Sessions { get; set; } = []; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; set; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; set; } + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 是否开启预热。 + /// + public bool PreheatEnabled { get; set; } + + /// + /// 预热小时数(空表示不启用)。 + /// + public int? PreheatHours { get; set; } + + /// + /// 活动门店。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 秒杀商品。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public SeckillMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 秒杀活动详情响应。 +/// +public sealed class SeckillDetailResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(timed/hourly)。 + /// + public string ActivityType { get; set; } = "timed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 整点秒杀场次。 + /// + public List Sessions { get; set; } = []; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; set; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 是否开启预热。 + /// + public bool PreheatEnabled { get; set; } + + /// + /// 预热小时数(空表示不启用)。 + /// + public int? PreheatHours { get; set; } + + /// + /// 活动门店。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 秒杀商品。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public SeckillMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 秒杀活动统计响应。 +/// +public sealed class SeckillStatsResponse +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; set; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; set; } + + /// + /// 本月秒杀销量。 + /// + public int MonthlySeckillSalesCount { get; set; } + + /// + /// 秒杀转化率。 + /// + public decimal ConversionRate { get; set; } +} + +/// +/// 秒杀场次请求。 +/// +public sealed class SeckillSessionRequest +{ + /// + /// 场次开始时间(HH:mm)。 + /// + public string StartTime { get; set; } = string.Empty; + + /// + /// 场次持续时长(分钟)。 + /// + public int DurationMinutes { get; set; } +} + +/// +/// 秒杀场次响应。 +/// +public sealed class SeckillSessionResponse +{ + /// + /// 场次开始时间(HH:mm)。 + /// + public string StartTime { get; set; } = string.Empty; + + /// + /// 场次持续时长(分钟)。 + /// + public int DurationMinutes { get; set; } +} + +/// +/// 秒杀商品请求。 +/// +public sealed class SeckillSaveProductRequest +{ + /// + /// 商品 ID。 + /// + public string ProductId { get; set; } = string.Empty; + + /// + /// 秒杀价。 + /// + public decimal SeckillPrice { get; set; } + + /// + /// 限量库存(份)。 + /// + public int StockLimit { get; set; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } +} + +/// +/// 秒杀商品响应。 +/// +public sealed class SeckillProductResponse +{ + /// + /// 商品 ID。 + /// + public string ProductId { 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; + + /// + /// SPU 编码。 + /// + public string SpuCode { get; set; } = string.Empty; + + /// + /// 商品状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; set; } = "off_shelf"; + + /// + /// 原价。 + /// + public decimal OriginalPrice { get; set; } + + /// + /// 秒杀价。 + /// + public decimal SeckillPrice { get; set; } + + /// + /// 限量库存(份)。 + /// + public int StockLimit { get; set; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 已售数量。 + /// + public int SoldCount { get; set; } +} + +/// +/// 秒杀指标请求。 +/// +public sealed class SeckillMetricsRequest +{ + /// + /// 参与人数。 + /// + public int ParticipantCount { get; set; } + + /// + /// 成交单数。 + /// + public int DealCount { get; set; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; set; } + + /// + /// 本月秒杀销量(单)。 + /// + public int MonthlySeckillSalesCount { get; set; } +} + +/// +/// 秒杀指标响应。 +/// +public sealed class SeckillMetricsResponse +{ + /// + /// 参与人数。 + /// + public int ParticipantCount { get; set; } + + /// + /// 成交单数。 + /// + public int DealCount { get; set; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; set; } + + /// + /// 本月秒杀销量(单)。 + /// + public int MonthlySeckillSalesCount { get; set; } +} + +/// +/// 秒杀商品分类选择器请求。 +/// +public sealed class SeckillPickerCategoriesRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; +} + +/// +/// 秒杀商品分类选择器响应项。 +/// +public sealed class SeckillPickerCategoryItemResponse +{ + /// + /// 分类 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 分类名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 商品数量。 + /// + public int ProductCount { get; set; } +} + +/// +/// 秒杀商品选择器请求。 +/// +public sealed class SeckillPickerProductsRequest +{ + /// + /// 门店 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 sealed class SeckillPickerProductItemResponse +{ + /// + /// 商品 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; } + + /// + /// 库存。 + /// + public int Stock { 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/MarketingSeckillController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingSeckillController.cs new file mode 100644 index 0000000..afd663d --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingSeckillController.cs @@ -0,0 +1,446 @@ +using System.Globalization; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Coupons.Seckill.Commands; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Application.App.Coupons.Seckill.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; + +/// +/// 营销中心秒杀活动管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/marketing/seckill")] +public sealed class MarketingSeckillController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 获取秒杀活动列表。 + /// + [HttpGet("list")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] SeckillListRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new GetSeckillCampaignListQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword, + Status = request.Status, + Page = request.Page, + PageSize = request.PageSize + }, cancellationToken); + + return ApiResponse.Ok(new SeckillListResultResponse + { + Items = result.Items.Select(MapListItem).ToList(), + Total = result.TotalCount, + Page = result.Page, + PageSize = result.PageSize, + Stats = new SeckillStatsResponse + { + TotalCount = result.Stats.TotalCount, + OngoingCount = result.Stats.OngoingCount, + MonthlySeckillSalesCount = result.Stats.MonthlySeckillSalesCount, + ConversionRate = result.Stats.ConversionRate + } + }); + } + + /// + /// 获取秒杀活动详情。 + /// + [HttpGet("detail")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] SeckillDetailRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetSeckillCampaignDetailQuery + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)) + }, cancellationToken); + + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "活动不存在"); + } + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 保存秒杀活动(新增/编辑)。 + /// + [HttpPost("save")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Save( + [FromBody] SaveSeckillRequest 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 SaveSeckillCampaignCommand + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id), + Name = request.Name, + ActivityType = request.ActivityType, + 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)), + Sessions = (request.Sessions ?? []) + .Select(item => new SeckillSessionRuleDto + { + StartTime = StoreApiHelpers.ParseRequiredTime(item.StartTime, "sessions.startTime"), + DurationMinutes = item.DurationMinutes + }) + .ToList(), + Channels = request.Channels ?? [], + PerUserLimit = request.PerUserLimit, + PreheatEnabled = request.PreheatEnabled, + PreheatHours = request.PreheatHours, + StoreIds = resolvedStoreIds, + Products = request.Products.Select(item => new SeckillSaveProductInputDto + { + ProductId = StoreApiHelpers.ParseRequiredSnowflake(item.ProductId, nameof(item.ProductId)), + SeckillPrice = item.SeckillPrice, + StockLimit = item.StockLimit, + PerUserLimit = item.PerUserLimit + }).ToList(), + Metrics = request.Metrics is null + ? null + : new SeckillMetricsDto + { + ParticipantCount = request.Metrics.ParticipantCount, + DealCount = request.Metrics.DealCount, + ConversionRate = request.Metrics.ConversionRate, + MonthlySeckillSalesCount = request.Metrics.MonthlySeckillSalesCount + } + }, cancellationToken); + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 修改秒杀活动状态。 + /// + [HttpPost("status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangeStatus( + [FromBody] ChangeSeckillStatusRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new ChangeSeckillCampaignStatusCommand + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)), + Status = request.Status + }, cancellationToken); + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 删除秒杀活动。 + /// + [HttpPost("delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete( + [FromBody] DeleteSeckillRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + await mediator.Send(new DeleteSeckillCampaignCommand + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)) + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 获取秒杀选品分类。 + /// + [HttpGet("picker/categories")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> PickerCategories( + [FromQuery] SeckillPickerCategoriesRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetSeckillPickerCategoriesQuery + { + OperationStoreId = operationStoreId + }, cancellationToken); + + return ApiResponse>.Ok(result + .Select(item => new SeckillPickerCategoryItemResponse + { + Id = item.Id.ToString(), + Name = item.Name, + ProductCount = item.ProductCount + }) + .ToList()); + } + + /// + /// 获取秒杀选品商品。 + /// + [HttpGet("picker/products")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> PickerProducts( + [FromQuery] SeckillPickerProductsRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetSeckillPickerProductsQuery + { + OperationStoreId = operationStoreId, + CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId), + Keyword = request.Keyword, + Limit = request.Limit + }, cancellationToken); + + return ApiResponse>.Ok(result + .Select(item => new SeckillPickerProductItemResponse + { + 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> 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> ResolveStoreIdsForSaveAsync( + IEnumerable? 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 SeckillListItemResponse MapListItem(SeckillListItemDto source) + { + return new SeckillListItemResponse + { + Id = source.Id.ToString(), + Name = source.Name, + ActivityType = source.Rules.ActivityType, + StartDate = ToDateOnly(source.Rules.StartDate), + EndDate = ToDateOnly(source.Rules.EndDate), + TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart), + TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd), + Sessions = source.Rules.Sessions.Select(MapSession).ToList(), + Status = source.Status, + DisplayStatus = source.DisplayStatus, + IsDimmed = source.IsDimmed, + Channels = source.Rules.Channels.ToList(), + PerUserLimit = source.Rules.PerUserLimit, + PreheatEnabled = source.Rules.PreheatEnabled, + PreheatHours = source.Rules.PreheatHours, + 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 SeckillDetailResponse MapDetail(SeckillDetailDto source) + { + return new SeckillDetailResponse + { + Id = source.Id.ToString(), + Name = source.Name, + ActivityType = source.Rules.ActivityType, + StartDate = ToDateOnly(source.Rules.StartDate), + EndDate = ToDateOnly(source.Rules.EndDate), + TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart), + TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd), + Sessions = source.Rules.Sessions.Select(MapSession).ToList(), + Status = source.Status, + DisplayStatus = source.DisplayStatus, + Channels = source.Rules.Channels.ToList(), + PerUserLimit = source.Rules.PerUserLimit, + PreheatEnabled = source.Rules.PreheatEnabled, + PreheatHours = source.Rules.PreheatHours, + 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 SeckillSessionResponse MapSession(SeckillSessionRuleDto source) + { + return new SeckillSessionResponse + { + StartTime = StoreApiHelpers.ToHHmm(source.StartTime), + DurationMinutes = source.DurationMinutes + }; + } + + private static SeckillProductResponse MapProduct(SeckillProductRuleDto source) + { + return new SeckillProductResponse + { + ProductId = source.ProductId.ToString(), + CategoryId = source.CategoryId.ToString(), + CategoryName = source.CategoryName, + Name = source.Name, + SpuCode = source.SpuCode, + Status = source.Status, + OriginalPrice = source.OriginalPrice, + SeckillPrice = source.SeckillPrice, + StockLimit = source.StockLimit, + PerUserLimit = source.PerUserLimit, + SoldCount = source.SoldCount + }; + } + + private static SeckillMetricsResponse MapMetrics(SeckillMetricsDto source) + { + return new SeckillMetricsResponse + { + ParticipantCount = source.ParticipantCount, + DealCount = source.DealCount, + ConversionRate = source.ConversionRate, + MonthlySeckillSalesCount = source.MonthlySeckillSalesCount + }; + } + + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/ChangeSeckillCampaignStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/ChangeSeckillCampaignStatusCommand.cs new file mode 100644 index 0000000..0e7457b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/ChangeSeckillCampaignStatusCommand.cs @@ -0,0 +1,27 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands; + +/// +/// 修改秒杀活动状态命令。 +/// +public sealed class ChangeSeckillCampaignStatusCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } + + /// + /// 状态(active/completed)。 + /// + public string Status { get; init; } = "completed"; +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/DeleteSeckillCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/DeleteSeckillCampaignCommand.cs new file mode 100644 index 0000000..e1e5da4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/DeleteSeckillCampaignCommand.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands; + +/// +/// 删除秒杀活动命令。 +/// +public sealed class DeleteSeckillCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/SaveSeckillCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/SaveSeckillCampaignCommand.cs new file mode 100644 index 0000000..37f4819 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Commands/SaveSeckillCampaignCommand.cs @@ -0,0 +1,90 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands; + +/// +/// 保存秒杀活动命令。 +/// +public sealed class SaveSeckillCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID(编辑时传)。 + /// + public long? CampaignId { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 活动类型(timed/hourly)。 + /// + public string ActivityType { get; init; } = "timed"; + + /// + /// 活动开始日期(UTC 日期)。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 活动结束日期(UTC 日期)。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 每日开始时间。 + /// + public TimeSpan? TimeStart { get; init; } + + /// + /// 每日结束时间。 + /// + public TimeSpan? TimeEnd { get; init; } + + /// + /// 整点秒杀场次。 + /// + public IReadOnlyCollection Sessions { get; init; } = []; + + /// + /// 适用渠道。 + /// + public IReadOnlyCollection Channels { get; init; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 是否开启预热。 + /// + public bool PreheatEnabled { get; init; } + + /// + /// 预热小时数。 + /// + public int? PreheatHours { get; init; } + + /// + /// 活动门店。 + /// + public IReadOnlyCollection StoreIds { get; init; } = []; + + /// + /// 商品配置输入。 + /// + public IReadOnlyCollection Products { get; init; } = []; + + /// + /// 活动指标。 + /// + public SeckillMetricsDto? Metrics { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillDetailDto.cs new file mode 100644 index 0000000..ec2ec58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillDetailDto.cs @@ -0,0 +1,39 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动详情 DTO。 +/// +public sealed class SeckillDetailDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; init; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 活动规则。 + /// + public SeckillRulesDto Rules { get; init; } = new(); +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListItemDto.cs new file mode 100644 index 0000000..dd202f0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListItemDto.cs @@ -0,0 +1,44 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动列表项 DTO。 +/// +public sealed class SeckillListItemDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; init; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; init; } + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 活动规则。 + /// + public SeckillRulesDto Rules { get; init; } = new(); +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListResultDto.cs new file mode 100644 index 0000000..0a6ec95 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillListResultDto.cs @@ -0,0 +1,34 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动列表结果 DTO。 +/// +public sealed class SeckillListResultDto +{ + /// + /// 列表数据。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总条数。 + /// + public int TotalCount { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } + + /// + /// 统计信息。 + /// + public SeckillStatsDto Stats { get; init; } = new(); +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillMetricsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillMetricsDto.cs new file mode 100644 index 0000000..50b31ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillMetricsDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动指标 DTO。 +/// +public sealed class SeckillMetricsDto +{ + /// + /// 参与人数。 + /// + public int ParticipantCount { get; init; } + + /// + /// 成交单数。 + /// + public int DealCount { get; init; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; init; } + + /// + /// 本月秒杀销量(单)。 + /// + public int MonthlySeckillSalesCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerCategoryItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerCategoryItemDto.cs new file mode 100644 index 0000000..1998898 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerCategoryItemDto.cs @@ -0,0 +1,24 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀选品分类项 DTO。 +/// +public sealed class SeckillPickerCategoryItemDto +{ + /// + /// 分类 ID。 + /// + public long Id { get; init; } + + /// + /// 分类名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 商品数量。 + /// + public int ProductCount { get; init; } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerProductItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerProductItemDto.cs new file mode 100644 index 0000000..81b1fc2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillPickerProductItemDto.cs @@ -0,0 +1,49 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀选品商品项 DTO。 +/// +public sealed class SeckillPickerProductItemDto +{ + /// + /// 商品 ID。 + /// + public long Id { get; init; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; init; } + + /// + /// 分类名称。 + /// + public string CategoryName { get; init; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 售价。 + /// + public decimal Price { get; init; } + + /// + /// 库存。 + /// + public int Stock { get; init; } + + /// + /// SPU 编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; init; } = "off_shelf"; +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillProductRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillProductRuleDto.cs new file mode 100644 index 0000000..4f9b4c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillProductRuleDto.cs @@ -0,0 +1,62 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀商品规则 DTO。 +/// +public sealed class SeckillProductRuleDto +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; init; } + + /// + /// 分类名称。 + /// + public string CategoryName { get; init; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// SPU 编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 商品状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; init; } = "off_shelf"; + + /// + /// 原价。 + /// + public decimal OriginalPrice { get; init; } + + /// + /// 秒杀价。 + /// + public decimal SeckillPrice { get; init; } + + /// + /// 限量库存(份)。 + /// + public int StockLimit { get; init; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 已售数量。 + /// + public int SoldCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillRulesDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillRulesDto.cs new file mode 100644 index 0000000..34a40c3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillRulesDto.cs @@ -0,0 +1,72 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动规则 DTO。 +/// +public sealed class SeckillRulesDto +{ + /// + /// 活动类型(timed/hourly)。 + /// + public string ActivityType { get; init; } = "timed"; + + /// + /// 活动开始日期(UTC 日期)。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 活动结束日期(UTC 日期)。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 每日开始时间。 + /// + public TimeSpan? TimeStart { get; init; } + + /// + /// 每日结束时间。 + /// + public TimeSpan? TimeEnd { get; init; } + + /// + /// 整点秒杀场次。 + /// + public IReadOnlyList Sessions { get; init; } = []; + + /// + /// 适用渠道(delivery/pickup/dine_in)。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 是否开启预热。 + /// + public bool PreheatEnabled { get; init; } + + /// + /// 预热小时数(空表示不启用)。 + /// + public int? PreheatHours { get; init; } + + /// + /// 活动门店 ID。 + /// + public IReadOnlyList StoreIds { get; init; } = []; + + /// + /// 秒杀商品。 + /// + public IReadOnlyList Products { get; init; } = []; + + /// + /// 活动指标。 + /// + public SeckillMetricsDto Metrics { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSaveProductInputDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSaveProductInputDto.cs new file mode 100644 index 0000000..ff0b326 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSaveProductInputDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀保存商品输入 DTO。 +/// +public sealed class SeckillSaveProductInputDto +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 秒杀价。 + /// + public decimal SeckillPrice { get; init; } + + /// + /// 限量库存(份)。 + /// + public int StockLimit { get; init; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSessionRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSessionRuleDto.cs new file mode 100644 index 0000000..c821a1b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillSessionRuleDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀场次规则 DTO。 +/// +public sealed class SeckillSessionRuleDto +{ + /// + /// 开始时间(HH:mm)。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 持续时长(分钟)。 + /// + public int DurationMinutes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillStatsDto.cs new file mode 100644 index 0000000..bd74a37 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Dto/SeckillStatsDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +/// +/// 秒杀活动统计 DTO。 +/// +public sealed class SeckillStatsDto +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; init; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; init; } + + /// + /// 本月秒杀销量(单)。 + /// + public int MonthlySeckillSalesCount { get; init; } + + /// + /// 秒杀转化率(百分比)。 + /// + public decimal ConversionRate { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/ChangeSeckillCampaignStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/ChangeSeckillCampaignStatusCommandHandler.cs new file mode 100644 index 0000000..017fa62 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/ChangeSeckillCampaignStatusCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Commands; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill.Handlers; + +/// +/// 修改秒杀活动状态命令处理器。 +/// +public sealed class ChangeSeckillCampaignStatusCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(ChangeSeckillCampaignStatusCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.Seckill, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var rules = SeckillMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!rules.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + } + + campaign.Status = SeckillMapping.ParseStatus(request.Status); + + await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return SeckillDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow); + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/DeleteSeckillCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/DeleteSeckillCampaignCommandHandler.cs new file mode 100644 index 0000000..70c80af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/DeleteSeckillCampaignCommandHandler.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill.Handlers; + +/// +/// 删除秒杀活动命令处理器。 +/// +public sealed class DeleteSeckillCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(DeleteSeckillCampaignCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.Seckill, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var rules = SeckillMapping.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; + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignDetailQueryHandler.cs new file mode 100644 index 0000000..405443b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignDetailQueryHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill.Handlers; + +/// +/// 秒杀活动详情查询处理器。 +/// +public sealed class GetSeckillCampaignDetailQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetSeckillCampaignDetailQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.Seckill, + cancellationToken); + + if (campaign is null) + { + return null; + } + + var rules = SeckillMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!rules.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + } + + return SeckillDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow); + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignListQueryHandler.cs new file mode 100644 index 0000000..e8c63f3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillCampaignListQueryHandler.cs @@ -0,0 +1,113 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill.Handlers; + +/// +/// 秒杀活动列表查询处理器。 +/// +public sealed class GetSeckillCampaignListQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetSeckillCampaignListQuery request, CancellationToken cancellationToken) + { + var page = Math.Max(1, request.Page); + var pageSize = Math.Clamp(request.PageSize, 1, 200); + + if (!SeckillMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus)) + { + throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法"); + } + + if (request.VisibleStoreIds.Count == 0) + { + return new SeckillListResultDto + { + Items = [], + TotalCount = 0, + Page = page, + PageSize = pageSize, + Stats = new SeckillStatsDto() + }; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync( + tenantId, + PromotionType.Seckill, + cancellationToken); + + if (campaigns.Count == 0) + { + return new SeckillListResultDto + { + Items = [], + TotalCount = 0, + Page = page, + PageSize = pageSize, + Stats = new SeckillStatsDto() + }; + } + + var nowUtc = DateTime.UtcNow; + + var visibleItems = new List(campaigns.Count); + foreach (var campaign in campaigns) + { + var rules = SeckillMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!SeckillMapping.HasVisibleStore(rules, request.VisibleStoreIds)) + { + continue; + } + + visibleItems.Add(SeckillDtoFactory.ToListItemDto(campaign, rules, nowUtc)); + } + + var stats = SeckillDtoFactory.ToStatsDto(visibleItems); + + IEnumerable 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 SeckillListResultDto + { + Items = paged, + TotalCount = total, + Page = page, + PageSize = pageSize, + Stats = stats + }; + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerCategoriesQueryHandler.cs new file mode 100644 index 0000000..ccc1d94 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerCategoriesQueryHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Application.App.Coupons.Seckill.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Handlers; + +/// +/// 秒杀选品分类查询处理器。 +/// +public sealed class GetSeckillPickerCategoriesQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetSeckillPickerCategoriesQuery 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 SeckillPickerCategoryItemDto + { + Id = item.Id, + Name = item.Name, + ProductCount = productCountLookup.GetValueOrDefault(item.Id, 0) + }) + .ToList(); + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerProductsQueryHandler.cs new file mode 100644 index 0000000..86d1050 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/GetSeckillPickerProductsQueryHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Application.App.Coupons.Seckill.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Handlers; + +/// +/// 秒杀选品商品查询处理器。 +/// +public sealed class GetSeckillPickerProductsQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetSeckillPickerProductsQuery 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 SeckillPickerProductItemDto + { + 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 = SeckillMapping.ToProductStatusText(item.Status, item.SoldoutMode) + }) + .ToList(); + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/SaveSeckillCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/SaveSeckillCampaignCommandHandler.cs new file mode 100644 index 0000000..2c841b8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Handlers/SaveSeckillCampaignCommandHandler.cs @@ -0,0 +1,178 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Commands; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill.Handlers; + +/// +/// 保存秒杀活动命令处理器。 +/// +public sealed class SaveSeckillCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(SaveSeckillCampaignCommand 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; + SeckillMetricsDto? fallbackMetrics = null; + var soldCountLookup = new Dictionary(); + if (request.CampaignId.HasValue) + { + campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId.Value, + tenantId, + PromotionType.Seckill, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var existingRules = SeckillMapping.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.SeckillPrice > normalizedOriginalPrice) + { + throw new BusinessException(ErrorCodes.BadRequest, $"商品[{product.Name}]秒杀价不能高于原价"); + } + + soldCountLookup.TryGetValue(item.ProductId, out var soldCount); + + return new SeckillProductRuleDto + { + ProductId = product.Id, + CategoryId = product.CategoryId, + CategoryName = categoryNameLookup.GetValueOrDefault(product.CategoryId, string.Empty), + Name = product.Name, + SpuCode = product.SpuCode, + Status = SeckillMapping.ToProductStatusText(product.Status, product.SoldoutMode), + OriginalPrice = normalizedOriginalPrice, + SeckillPrice = item.SeckillPrice, + StockLimit = item.StockLimit, + PerUserLimit = item.PerUserLimit, + SoldCount = soldCount + }; + }) + .ToList(); + + var normalizedRules = SeckillMapping.NormalizeRulesForSave( + request.ActivityType, + request.StartDate, + request.EndDate, + request.TimeStart, + request.TimeEnd, + request.Sessions, + request.Channels, + request.PerUserLimit, + request.PreheatEnabled, + request.PreheatHours, + request.StoreIds, + normalizedProducts, + request.Metrics, + fallbackMetrics); + + var nowUtc = DateTime.UtcNow; + var campaignWindow = SeckillMapping.ResolveCampaignWindow(normalizedRules, nowUtc); + if (campaign is null) + { + campaign = SeckillDtoFactory.CreateNewCampaign( + normalizedName, + campaignWindow.StartAt, + campaignWindow.EndAt, + SeckillMapping.SerializeRules(normalizedRules)); + + await promotionCampaignRepository.AddAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return SeckillDtoFactory.ToDetailDto(campaign, normalizedRules, nowUtc); + } + + campaign.Name = normalizedName; + campaign.StartAt = campaignWindow.StartAt; + campaign.EndAt = campaignWindow.EndAt; + campaign.RulesJson = SeckillMapping.SerializeRules(normalizedRules); + + if (campaign.Status != PromotionStatus.Completed) + { + campaign.Status = PromotionStatus.Active; + } + + await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return SeckillDtoFactory.ToDetailDto(campaign, normalizedRules, DateTime.UtcNow); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignDetailQuery.cs new file mode 100644 index 0000000..6000ecf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignDetailQuery.cs @@ -0,0 +1,22 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries; + +/// +/// 查询秒杀活动详情。 +/// +public sealed class GetSeckillCampaignDetailQuery : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignListQuery.cs new file mode 100644 index 0000000..3f64f80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillCampaignListQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries; + +/// +/// 查询秒杀活动列表。 +/// +public sealed class GetSeckillCampaignListQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 状态筛选(ongoing/upcoming/ended)。 + /// + public string? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 4; +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerCategoriesQuery.cs new file mode 100644 index 0000000..5920812 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerCategoriesQuery.cs @@ -0,0 +1,17 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries; + +/// +/// 查询秒杀选品分类列表。 +/// +public sealed class GetSeckillPickerCategoriesQuery : IRequest> +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerProductsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerProductsQuery.cs new file mode 100644 index 0000000..d8b3952 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/Queries/GetSeckillPickerProductsQuery.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries; + +/// +/// 查询秒杀选品商品列表。 +/// +public sealed class GetSeckillPickerProductsQuery : IRequest> +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 分类 ID(可空)。 + /// + public long? CategoryId { get; init; } + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 条数上限。 + /// + public int? Limit { get; init; } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillDtoFactory.cs new file mode 100644 index 0000000..0602c5e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillDtoFactory.cs @@ -0,0 +1,105 @@ +using TakeoutSaaS.Application.App.Coupons.Seckill.Dto; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; + +namespace TakeoutSaaS.Application.App.Coupons.Seckill; + +/// +/// 秒杀 DTO 映射工厂。 +/// +internal static class SeckillDtoFactory +{ + /// + /// 构建列表项 DTO。 + /// + public static SeckillListItemDto ToListItemDto( + PromotionCampaign campaign, + SeckillRulesDto rules, + DateTime nowUtc) + { + var displayStatus = SeckillMapping.ResolveDisplayStatus(campaign, rules, nowUtc); + return new SeckillListItemDto + { + Id = campaign.Id, + Name = campaign.Name, + Status = SeckillMapping.ToStatusText(campaign.Status), + DisplayStatus = displayStatus, + IsDimmed = SeckillMapping.IsDimmed(displayStatus), + UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt, + Rules = rules + }; + } + + /// + /// 构建详情 DTO。 + /// + public static SeckillDetailDto ToDetailDto( + PromotionCampaign campaign, + SeckillRulesDto rules, + DateTime nowUtc) + { + return new SeckillDetailDto + { + Id = campaign.Id, + Name = campaign.Name, + Status = SeckillMapping.ToStatusText(campaign.Status), + DisplayStatus = SeckillMapping.ResolveDisplayStatus(campaign, rules, nowUtc), + UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt, + Rules = rules + }; + } + + /// + /// 构建统计 DTO。 + /// + public static SeckillStatsDto ToStatsDto(IReadOnlyCollection items) + { + if (items.Count == 0) + { + return new SeckillStatsDto(); + } + + var totalParticipantCount = items.Sum(item => item.Rules.Metrics.ParticipantCount); + var totalDealCount = items.Sum(item => item.Rules.Metrics.DealCount); + + decimal conversionRate = 0m; + if (totalParticipantCount > 0) + { + conversionRate = decimal.Round( + totalDealCount * 100m / totalParticipantCount, + 2, + MidpointRounding.AwayFromZero); + } + + return new SeckillStatsDto + { + TotalCount = items.Count, + OngoingCount = items.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)), + MonthlySeckillSalesCount = items.Sum(item => item.Rules.Metrics.MonthlySeckillSalesCount), + ConversionRate = conversionRate + }; + } + + /// + /// 构建默认新增活动实体。 + /// + public static PromotionCampaign CreateNewCampaign( + string name, + DateTime startAt, + DateTime endAt, + string rulesJson) + { + return new PromotionCampaign + { + Name = name, + PromotionType = PromotionType.Seckill, + Status = PromotionStatus.Active, + StartAt = startAt, + EndAt = endAt, + RulesJson = rulesJson, + AudienceDescription = null, + Budget = null, + BannerUrl = null + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillMapping.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillMapping.cs new file mode 100644 index 0000000..833305b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Seckill/SeckillMapping.cs @@ -0,0 +1,652 @@ +using System.Text.Json; +using TakeoutSaaS.Application.App.Coupons.Seckill.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.Seckill; + +/// +/// 秒杀映射与规则校验辅助。 +/// +internal static class SeckillMapping +{ + private const string ActivityTypeTimed = "timed"; + private const string ActivityTypeHourly = "hourly"; + + private const string DisplayStatusEnded = "ended"; + private const string DisplayStatusOngoing = "ongoing"; + private const string DisplayStatusUpcoming = "upcoming"; + + private static readonly HashSet AllowedChannels = + [ + "delivery", + "pickup", + "dine_in" + ]; + + private static readonly HashSet AllowedDisplayStatuses = + [ + DisplayStatusOngoing, + DisplayStatusUpcoming, + DisplayStatusEnded + ]; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + /// + /// 校验并标准化展示状态筛选。 + /// + 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; + } + + /// + /// 解析状态文本为领域状态。 + /// + 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 参数不合法") + }; + } + + /// + /// 输出状态文本。 + /// + public static string ToStatusText(PromotionStatus status) + { + return status switch + { + PromotionStatus.Active => "active", + PromotionStatus.Completed => "completed", + _ => "completed" + }; + } + + /// + /// 商品状态输出文本。 + /// + public static string ToProductStatusText(ProductStatus status, ProductSoldoutMode? soldoutMode) + { + if (soldoutMode.HasValue) + { + return "sold_out"; + } + + return status switch + { + ProductStatus.OnSale => "on_sale", + _ => "off_shelf" + }; + } + + /// + /// 将开始时间规范为当天 00:00:00(UTC)。 + /// + public static DateTime NormalizeStartOfDay(DateTime dateTime) + { + var utc = dateTime.Kind == DateTimeKind.Utc + ? dateTime + : DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + return utc.Date; + } + + /// + /// 将结束时间规范为当天 23:59:59.9999999(UTC)。 + /// + 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); + } + + /// + /// 解析活动规则。 + /// + public static SeckillRulesDto DeserializeRules(string? rulesJson, long campaignId) + { + if (string.IsNullOrWhiteSpace(rulesJson)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失"); + } + + var payload = JsonSerializer.Deserialize(rulesJson, JsonOptions) + ?? throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则格式错误"); + + return NormalizeRulesForSave( + payload.ActivityType, + payload.StartDate, + payload.EndDate, + payload.TimeStart, + payload.TimeEnd, + payload.Sessions, + payload.Channels, + payload.PerUserLimit, + payload.PreheatEnabled, + payload.PreheatHours, + payload.StoreIds, + payload.Products, + payload.Metrics, + null); + } + + /// + /// 序列化活动规则。 + /// + public static string SerializeRules(SeckillRulesDto rules) + { + return JsonSerializer.Serialize(rules, JsonOptions); + } + + /// + /// 标准化并校验保存规则。 + /// + public static SeckillRulesDto NormalizeRulesForSave( + string? activityType, + DateTime? startDate, + DateTime? endDate, + TimeSpan? timeStart, + TimeSpan? timeEnd, + IReadOnlyCollection? sessions, + IReadOnlyCollection? channels, + int? perUserLimit, + bool preheatEnabled, + int? preheatHours, + IReadOnlyCollection? storeIds, + IReadOnlyCollection? products, + SeckillMetricsDto? metrics, + SeckillMetricsDto? fallbackMetrics) + { + var normalizedActivityType = NormalizeActivityType(activityType); + + var (normalizedStartDate, normalizedEndDate) = NormalizeDateRange( + normalizedActivityType, + startDate, + endDate); + var (normalizedTimeStart, normalizedTimeEnd) = NormalizeTimeRange( + normalizedActivityType, + timeStart, + timeEnd); + + var normalizedSessions = NormalizeSessions(normalizedActivityType, sessions); + var normalizedChannels = NormalizeChannels(channels); + var normalizedPerUserLimit = NormalizeOptionalLimit(perUserLimit, "perUserLimit 参数不合法"); + var normalizedPreheatHours = NormalizePreheat(preheatEnabled, preheatHours); + var normalizedStoreIds = NormalizeStoreIds(storeIds); + var normalizedProducts = NormalizeProducts(products, normalizedPerUserLimit); + var normalizedMetrics = NormalizeMetrics(metrics, fallbackMetrics); + + return new SeckillRulesDto + { + ActivityType = normalizedActivityType, + StartDate = normalizedStartDate, + EndDate = normalizedEndDate, + TimeStart = normalizedTimeStart, + TimeEnd = normalizedTimeEnd, + Sessions = normalizedSessions, + Channels = normalizedChannels, + PerUserLimit = normalizedPerUserLimit, + PreheatEnabled = preheatEnabled, + PreheatHours = normalizedPreheatHours, + StoreIds = normalizedStoreIds, + Products = normalizedProducts, + Metrics = normalizedMetrics + }; + } + + /// + /// 判断活动是否对可见门店可见。 + /// + public static bool HasVisibleStore(SeckillRulesDto rules, IReadOnlyCollection visibleStoreIds) + { + if (visibleStoreIds.Count == 0) + { + return false; + } + + return rules.StoreIds.Any(visibleStoreIds.Contains); + } + + /// + /// 解析活动展示状态。 + /// + public static string ResolveDisplayStatus( + PromotionCampaign campaign, + SeckillRulesDto rules, + DateTime nowUtc) + { + if (campaign.Status is PromotionStatus.Completed or PromotionStatus.Paused) + { + return DisplayStatusEnded; + } + + if (string.Equals(rules.ActivityType, ActivityTypeHourly, StringComparison.Ordinal)) + { + return DisplayStatusOngoing; + } + + if (nowUtc < campaign.StartAt) + { + return DisplayStatusUpcoming; + } + + if (nowUtc > campaign.EndAt) + { + return DisplayStatusEnded; + } + + return DisplayStatusOngoing; + } + + /// + /// 判断是否弱化展示。 + /// + public static bool IsDimmed(string displayStatus) + { + return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal); + } + + /// + /// 计算活动实体起止时间窗口。 + /// + public static (DateTime StartAt, DateTime EndAt) ResolveCampaignWindow(SeckillRulesDto rules, DateTime nowUtc) + { + if (string.Equals(rules.ActivityType, ActivityTypeHourly, StringComparison.Ordinal)) + { + 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 NormalizeActivityType(string? value) + { + var candidate = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (candidate is not (ActivityTypeTimed or ActivityTypeHourly)) + { + throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法"); + } + + return candidate; + } + + private static (DateTime? StartDate, DateTime? EndDate) NormalizeDateRange( + string activityType, + DateTime? startDate, + DateTime? endDate) + { + if (string.Equals(activityType, ActivityTypeHourly, StringComparison.Ordinal)) + { + if (startDate.HasValue || endDate.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "整点秒杀不支持日期范围"); + } + + return (null, null); + } + + if (!startDate.HasValue || !endDate.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "startDate 与 endDate 不能为空"); + } + + var normalizedStart = NormalizeStartOfDay(startDate.Value); + var normalizedEnd = NormalizeStartOfDay(endDate.Value); + + if (normalizedStart > normalizedEnd) + { + throw new BusinessException(ErrorCodes.BadRequest, "活动开始日期不能晚于结束日期"); + } + + return (normalizedStart, normalizedEnd); + } + + private static (TimeSpan? TimeStart, TimeSpan? TimeEnd) NormalizeTimeRange( + string activityType, + TimeSpan? timeStart, + TimeSpan? timeEnd) + { + if (string.Equals(activityType, ActivityTypeHourly, StringComparison.Ordinal)) + { + if (timeStart.HasValue || timeEnd.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "整点秒杀不支持每日时段"); + } + + 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 NormalizeSessions( + string activityType, + IReadOnlyCollection? sessions) + { + if (string.Equals(activityType, ActivityTypeTimed, StringComparison.Ordinal)) + { + return []; + } + + var normalized = (sessions ?? Array.Empty()) + .Select(item => + { + var startTime = NormalizeSessionStartTime(item.StartTime); + var durationMinutes = NormalizeRequiredPositiveInt(item.DurationMinutes, "场次持续时长必须大于 0"); + + return new SeckillSessionRuleDto + { + StartTime = startTime, + DurationMinutes = durationMinutes + }; + }) + .OrderBy(item => item.StartTime) + .ToList(); + + if (normalized.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "sessions 不能为空"); + } + + var duplicateStartTime = normalized + .GroupBy(item => item.StartTime) + .FirstOrDefault(group => group.Count() > 1)? + .Key; + if (duplicateStartTime.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, $"场次时间重复: {duplicateStartTime.Value:hh\\:mm}"); + } + + return normalized; + } + + private static TimeSpan NormalizeSessionStartTime(TimeSpan value) + { + if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1)) + { + throw new BusinessException(ErrorCodes.BadRequest, "场次时间不合法"); + } + + return new TimeSpan(value.Hours, value.Minutes, 0); + } + + private static IReadOnlyList NormalizeChannels(IReadOnlyCollection? channels) + { + var normalized = (channels ?? Array.Empty()) + .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 int? NormalizePreheat(bool preheatEnabled, int? preheatHours) + { + if (!preheatEnabled) + { + return null; + } + + if (!preheatHours.HasValue || preheatHours.Value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "preheatHours 必须大于 0"); + } + + return preheatHours.Value; + } + + private static IReadOnlyList NormalizeStoreIds(IReadOnlyCollection? storeIds) + { + var normalized = (storeIds ?? Array.Empty()) + .Where(id => id > 0) + .Distinct() + .OrderBy(id => id) + .ToList(); + + if (normalized.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空"); + } + + return normalized; + } + + private static IReadOnlyList NormalizeProducts( + IReadOnlyCollection? products, + int? campaignPerUserLimit) + { + var normalized = (products ?? Array.Empty()) + .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 normalizedSeckillPrice = NormalizeMoney(item.SeckillPrice, "秒杀价必须大于 0"); + if (normalizedSeckillPrice > normalizedOriginalPrice) + { + throw new BusinessException(ErrorCodes.BadRequest, "秒杀价不能高于原价"); + } + + var normalizedStockLimit = NormalizeRequiredPositiveInt(item.StockLimit, "stockLimit 必须大于 0"); + var normalizedSoldCount = Math.Max(0, item.SoldCount); + if (normalizedSoldCount > normalizedStockLimit) + { + 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, "商品每人限购不能大于活动每人限购"); + } + + if (normalizedSoldCount >= normalizedStockLimit) + { + normalizedStatus = "sold_out"; + } + + return new SeckillProductRuleDto + { + ProductId = item.ProductId, + CategoryId = item.CategoryId, + CategoryName = (item.CategoryName ?? string.Empty).Trim(), + Name = normalizedName, + SpuCode = normalizedSpuCode, + Status = normalizedStatus, + OriginalPrice = normalizedOriginalPrice, + SeckillPrice = normalizedSeckillPrice, + StockLimit = normalizedStockLimit, + PerUserLimit = normalizedPerUserLimit, + SoldCount = normalizedSoldCount + }; + }) + .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 int NormalizeRequiredPositiveInt(int value, string errorMessage) + { + if (value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, errorMessage); + } + + return value; + } + + private static SeckillMetricsDto NormalizeMetrics(SeckillMetricsDto? metrics, SeckillMetricsDto? fallbackMetrics) + { + var source = metrics ?? fallbackMetrics; + if (source is null) + { + return new SeckillMetricsDto(); + } + + var participantCount = Math.Max(0, source.ParticipantCount); + var dealCount = Math.Max(0, source.DealCount); + var conversionRate = ClampPercent(source.ConversionRate); + + if (conversionRate <= 0m && participantCount > 0) + { + conversionRate = decimal.Round( + dealCount * 100m / participantCount, + 2, + MidpointRounding.AwayFromZero); + } + + return new SeckillMetricsDto + { + ParticipantCount = participantCount, + DealCount = dealCount, + ConversionRate = conversionRate, + MonthlySeckillSalesCount = Math.Max(0, source.MonthlySeckillSalesCount) + }; + } + + private static decimal ClampPercent(decimal value) + { + if (value <= 0) + { + return 0m; + } + + if (value >= 100) + { + return 100m; + } + + return decimal.Round(value, 2, MidpointRounding.AwayFromZero); + } + + private static decimal NormalizeMoney(decimal value, string errorMessage) + { + if (value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, errorMessage); + } + + return decimal.Round(value, 2, MidpointRounding.AwayFromZero); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs index e30893d..27575b0 100644 --- a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs @@ -23,5 +23,10 @@ public enum PromotionType /// /// 抽奖活动。 /// - Lottery = 3 + Lottery = 3, + + /// + /// 秒杀活动。 + /// + Seckill = 4 }