feat(marketing): implement tenant seckill backend module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m55s

This commit is contained in:
2026-03-02 13:08:56 +08:00
parent 0f3542f33f
commit c9e2226b48
31 changed files with 3113 additions and 2 deletions

View File

@@ -0,0 +1,700 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 秒杀活动列表查询请求。
/// </summary>
public sealed class SeckillListRequest
{
/// <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 SeckillDetailRequest
{
/// <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 SaveSeckillRequest
{
/// <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>
/// 活动类型timed/hourly
/// </summary>
public string ActivityType { get; set; } = "timed";
/// <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>
/// 整点秒杀场次。
/// </summary>
public List<SeckillSessionRequest>? Sessions { get; set; }
/// <summary>
/// 适用渠道delivery/pickup/dine_in
/// </summary>
public List<string>? Channels { get; set; }
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 是否开启预热。
/// </summary>
public bool PreheatEnabled { get; set; }
/// <summary>
/// 预热小时数(空表示不启用)。
/// </summary>
public int? PreheatHours { get; set; }
/// <summary>
/// 活动门店 ID。
/// </summary>
public List<string>? StoreIds { get; set; }
/// <summary>
/// 秒杀商品列表。
/// </summary>
public List<SeckillSaveProductRequest> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public SeckillMetricsRequest? Metrics { get; set; }
}
/// <summary>
/// 修改秒杀活动状态请求。
/// </summary>
public sealed class ChangeSeckillStatusRequest
{
/// <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 DeleteSeckillRequest
{
/// <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 SeckillListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<SeckillListItemResponse> 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 SeckillStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 秒杀活动列表项响应。
/// </summary>
public sealed class SeckillListItemResponse
{
/// <summary>
/// 活动 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 活动类型timed/hourly
/// </summary>
public string ActivityType { get; set; } = "timed";
/// <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>
/// 整点秒杀场次。
/// </summary>
public List<SeckillSessionResponse> Sessions { 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 bool PreheatEnabled { get; set; }
/// <summary>
/// 预热小时数(空表示不启用)。
/// </summary>
public int? PreheatHours { get; set; }
/// <summary>
/// 活动门店。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 秒杀商品。
/// </summary>
public List<SeckillProductResponse> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public SeckillMetricsResponse Metrics { get; set; } = new();
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 秒杀活动详情响应。
/// </summary>
public sealed class SeckillDetailResponse
{
/// <summary>
/// 活动 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 活动类型timed/hourly
/// </summary>
public string ActivityType { get; set; } = "timed";
/// <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>
/// 整点秒杀场次。
/// </summary>
public List<SeckillSessionResponse> Sessions { 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 bool PreheatEnabled { get; set; }
/// <summary>
/// 预热小时数(空表示不启用)。
/// </summary>
public int? PreheatHours { get; set; }
/// <summary>
/// 活动门店。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 秒杀商品。
/// </summary>
public List<SeckillProductResponse> Products { get; set; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public SeckillMetricsResponse Metrics { get; set; } = new();
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 秒杀活动统计响应。
/// </summary>
public sealed class SeckillStatsResponse
{
/// <summary>
/// 活动总数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; set; }
/// <summary>
/// 本月秒杀销量。
/// </summary>
public int MonthlySeckillSalesCount { get; set; }
/// <summary>
/// 秒杀转化率。
/// </summary>
public decimal ConversionRate { get; set; }
}
/// <summary>
/// 秒杀场次请求。
/// </summary>
public sealed class SeckillSessionRequest
{
/// <summary>
/// 场次开始时间HH:mm
/// </summary>
public string StartTime { get; set; } = string.Empty;
/// <summary>
/// 场次持续时长(分钟)。
/// </summary>
public int DurationMinutes { get; set; }
}
/// <summary>
/// 秒杀场次响应。
/// </summary>
public sealed class SeckillSessionResponse
{
/// <summary>
/// 场次开始时间HH:mm
/// </summary>
public string StartTime { get; set; } = string.Empty;
/// <summary>
/// 场次持续时长(分钟)。
/// </summary>
public int DurationMinutes { get; set; }
}
/// <summary>
/// 秒杀商品请求。
/// </summary>
public sealed class SeckillSaveProductRequest
{
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 秒杀价。
/// </summary>
public decimal SeckillPrice { get; set; }
/// <summary>
/// 限量库存(份)。
/// </summary>
public int StockLimit { get; set; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
}
/// <summary>
/// 秒杀商品响应。
/// </summary>
public sealed class SeckillProductResponse
{
/// <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 SeckillPrice { get; set; }
/// <summary>
/// 限量库存(份)。
/// </summary>
public int StockLimit { get; set; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
}
/// <summary>
/// 秒杀指标请求。
/// </summary>
public sealed class SeckillMetricsRequest
{
/// <summary>
/// 参与人数。
/// </summary>
public int ParticipantCount { get; set; }
/// <summary>
/// 成交单数。
/// </summary>
public int DealCount { get; set; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; set; }
/// <summary>
/// 本月秒杀销量(单)。
/// </summary>
public int MonthlySeckillSalesCount { get; set; }
}
/// <summary>
/// 秒杀指标响应。
/// </summary>
public sealed class SeckillMetricsResponse
{
/// <summary>
/// 参与人数。
/// </summary>
public int ParticipantCount { get; set; }
/// <summary>
/// 成交单数。
/// </summary>
public int DealCount { get; set; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; set; }
/// <summary>
/// 本月秒杀销量(单)。
/// </summary>
public int MonthlySeckillSalesCount { get; set; }
}
/// <summary>
/// 秒杀商品分类选择器请求。
/// </summary>
public sealed class SeckillPickerCategoriesRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 秒杀商品分类选择器响应项。
/// </summary>
public sealed class SeckillPickerCategoryItemResponse
{
/// <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 SeckillPickerProductsRequest
{
/// <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 SeckillPickerProductItemResponse
{
/// <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,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;
/// <summary>
/// 营销中心秒杀活动管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/marketing/seckill")]
public sealed class MarketingSeckillController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 获取秒杀活动列表。
/// </summary>
[HttpGet("list")]
[ProducesResponseType(typeof(ApiResponse<SeckillListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SeckillListResultResponse>> 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<SeckillListResultResponse>.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
}
});
}
/// <summary>
/// 获取秒杀活动详情。
/// </summary>
[HttpGet("detail")]
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SeckillDetailResponse>> 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<SeckillDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
}
return ApiResponse<SeckillDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存秒杀活动(新增/编辑)。
/// </summary>
[HttpPost("save")]
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SeckillDetailResponse>> 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<SeckillDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 修改秒杀活动状态。
/// </summary>
[HttpPost("status")]
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SeckillDetailResponse>> 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<SeckillDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 删除秒杀活动。
/// </summary>
[HttpPost("delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> 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<object>.Ok(null);
}
/// <summary>
/// 获取秒杀选品分类。
/// </summary>
[HttpGet("picker/categories")]
[ProducesResponseType(typeof(ApiResponse<List<SeckillPickerCategoryItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<SeckillPickerCategoryItemResponse>>> 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<List<SeckillPickerCategoryItemResponse>>.Ok(result
.Select(item => new SeckillPickerCategoryItemResponse
{
Id = item.Id.ToString(),
Name = item.Name,
ProductCount = item.ProductCount
})
.ToList());
}
/// <summary>
/// 获取秒杀选品商品。
/// </summary>
[HttpGet("picker/products")]
[ProducesResponseType(typeof(ApiResponse<List<SeckillPickerProductItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<SeckillPickerProductItemResponse>>> 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<List<SeckillPickerProductItemResponse>>.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<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 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);
}
}

View File

@@ -0,0 +1,27 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands;
/// <summary>
/// 修改秒杀活动状态命令。
/// </summary>
public sealed class ChangeSeckillCampaignStatusCommand : IRequest<SeckillDetailDto>
{
/// <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,21 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands;
/// <summary>
/// 删除秒杀活动命令。
/// </summary>
public sealed class DeleteSeckillCampaignCommand : IRequest<Unit>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,90 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Commands;
/// <summary>
/// 保存秒杀活动命令。
/// </summary>
public sealed class SaveSeckillCampaignCommand : IRequest<SeckillDetailDto>
{
/// <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>
/// 活动类型timed/hourly
/// </summary>
public string ActivityType { get; init; } = "timed";
/// <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>
/// 整点秒杀场次。
/// </summary>
public IReadOnlyCollection<SeckillSessionRuleDto> Sessions { get; init; } = [];
/// <summary>
/// 适用渠道。
/// </summary>
public IReadOnlyCollection<string> Channels { get; init; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 是否开启预热。
/// </summary>
public bool PreheatEnabled { get; init; }
/// <summary>
/// 预热小时数。
/// </summary>
public int? PreheatHours { get; init; }
/// <summary>
/// 活动门店。
/// </summary>
public IReadOnlyCollection<long> StoreIds { get; init; } = [];
/// <summary>
/// 商品配置输入。
/// </summary>
public IReadOnlyCollection<SeckillSaveProductInputDto> Products { get; init; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public SeckillMetricsDto? Metrics { get; init; }
}

View File

@@ -0,0 +1,39 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动详情 DTO。
/// </summary>
public sealed class SeckillDetailDto
{
/// <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 SeckillRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,44 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动列表项 DTO。
/// </summary>
public sealed class SeckillListItemDto
{
/// <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 SeckillRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,34 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动列表结果 DTO。
/// </summary>
public sealed class SeckillListResultDto
{
/// <summary>
/// 列表数据。
/// </summary>
public IReadOnlyList<SeckillListItemDto> 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 SeckillStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动指标 DTO。
/// </summary>
public sealed class SeckillMetricsDto
{
/// <summary>
/// 参与人数。
/// </summary>
public int ParticipantCount { get; init; }
/// <summary>
/// 成交单数。
/// </summary>
public int DealCount { get; init; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; init; }
/// <summary>
/// 本月秒杀销量(单)。
/// </summary>
public int MonthlySeckillSalesCount { get; init; }
}

View File

@@ -0,0 +1,24 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀选品分类项 DTO。
/// </summary>
public sealed class SeckillPickerCategoryItemDto
{
/// <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,49 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀选品商品项 DTO。
/// </summary>
public sealed class SeckillPickerProductItemDto
{
/// <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,62 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀商品规则 DTO。
/// </summary>
public sealed class SeckillProductRuleDto
{
/// <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 SeckillPrice { get; init; }
/// <summary>
/// 限量库存(份)。
/// </summary>
public int StockLimit { get; init; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
}

View File

@@ -0,0 +1,72 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动规则 DTO。
/// </summary>
public sealed class SeckillRulesDto
{
/// <summary>
/// 活动类型timed/hourly
/// </summary>
public string ActivityType { get; init; } = "timed";
/// <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>
/// 整点秒杀场次。
/// </summary>
public IReadOnlyList<SeckillSessionRuleDto> Sessions { get; init; } = [];
/// <summary>
/// 适用渠道delivery/pickup/dine_in
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 活动每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 是否开启预热。
/// </summary>
public bool PreheatEnabled { get; init; }
/// <summary>
/// 预热小时数(空表示不启用)。
/// </summary>
public int? PreheatHours { get; init; }
/// <summary>
/// 活动门店 ID。
/// </summary>
public IReadOnlyList<long> StoreIds { get; init; } = [];
/// <summary>
/// 秒杀商品。
/// </summary>
public IReadOnlyList<SeckillProductRuleDto> Products { get; init; } = [];
/// <summary>
/// 活动指标。
/// </summary>
public SeckillMetricsDto Metrics { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀保存商品输入 DTO。
/// </summary>
public sealed class SeckillSaveProductInputDto
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 秒杀价。
/// </summary>
public decimal SeckillPrice { get; init; }
/// <summary>
/// 限量库存(份)。
/// </summary>
public int StockLimit { get; init; }
/// <summary>
/// 商品每人限购(空表示不限)。
/// </summary>
public int? PerUserLimit { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀场次规则 DTO。
/// </summary>
public sealed class SeckillSessionRuleDto
{
/// <summary>
/// 开始时间HH:mm
/// </summary>
public TimeSpan StartTime { get; init; }
/// <summary>
/// 持续时长(分钟)。
/// </summary>
public int DurationMinutes { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
/// <summary>
/// 秒杀活动统计 DTO。
/// </summary>
public sealed class SeckillStatsDto
{
/// <summary>
/// 活动总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; init; }
/// <summary>
/// 本月秒杀销量(单)。
/// </summary>
public int MonthlySeckillSalesCount { get; init; }
/// <summary>
/// 秒杀转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; init; }
}

View File

@@ -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;
/// <summary>
/// 修改秒杀活动状态命令处理器。
/// </summary>
public sealed class ChangeSeckillCampaignStatusCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeSeckillCampaignStatusCommand, SeckillDetailDto>
{
/// <inheritdoc />
public async Task<SeckillDetailDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 删除秒杀活动命令处理器。
/// </summary>
public sealed class DeleteSeckillCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteSeckillCampaignCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> 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;
}
}

View File

@@ -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;
/// <summary>
/// 秒杀活动详情查询处理器。
/// </summary>
public sealed class GetSeckillCampaignDetailQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetSeckillCampaignDetailQuery, SeckillDetailDto?>
{
/// <inheritdoc />
public async Task<SeckillDetailDto?> 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);
}
}

View File

@@ -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;
/// <summary>
/// 秒杀活动列表查询处理器。
/// </summary>
public sealed class GetSeckillCampaignListQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetSeckillCampaignListQuery, SeckillListResultDto>
{
/// <inheritdoc />
public async Task<SeckillListResultDto> 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<SeckillListItemDto>(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<SeckillListItemDto> 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
};
}
}

View File

@@ -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;
/// <summary>
/// 秒杀选品分类查询处理器。
/// </summary>
public sealed class GetSeckillPickerCategoriesQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetSeckillPickerCategoriesQuery, IReadOnlyList<SeckillPickerCategoryItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<SeckillPickerCategoryItemDto>> 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();
}
}

View File

@@ -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;
/// <summary>
/// 秒杀选品商品查询处理器。
/// </summary>
public sealed class GetSeckillPickerProductsQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetSeckillPickerProductsQuery, IReadOnlyList<SeckillPickerProductItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<SeckillPickerProductItemDto>> 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();
}
}

View File

@@ -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;
/// <summary>
/// 保存秒杀活动命令处理器。
/// </summary>
public sealed class SaveSeckillCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveSeckillCampaignCommand, SeckillDetailDto>
{
/// <inheritdoc />
public async Task<SeckillDetailDto> 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<long, int>();
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);
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries;
/// <summary>
/// 查询秒杀活动详情。
/// </summary>
public sealed class GetSeckillCampaignDetailQuery : IRequest<SeckillDetailDto?>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries;
/// <summary>
/// 查询秒杀活动列表。
/// </summary>
public sealed class GetSeckillCampaignListQuery : IRequest<SeckillListResultDto>
{
/// <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,17 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries;
/// <summary>
/// 查询秒杀选品分类列表。
/// </summary>
public sealed class GetSeckillPickerCategoriesQuery : IRequest<IReadOnlyList<SeckillPickerCategoryItemDto>>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
}

View File

@@ -0,0 +1,32 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Seckill.Queries;
/// <summary>
/// 查询秒杀选品商品列表。
/// </summary>
public sealed class GetSeckillPickerProductsQuery : IRequest<IReadOnlyList<SeckillPickerProductItemDto>>
{
/// <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

@@ -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;
/// <summary>
/// 秒杀 DTO 映射工厂。
/// </summary>
internal static class SeckillDtoFactory
{
/// <summary>
/// 构建列表项 DTO。
/// </summary>
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
};
}
/// <summary>
/// 构建详情 DTO。
/// </summary>
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
};
}
/// <summary>
/// 构建统计 DTO。
/// </summary>
public static SeckillStatsDto ToStatsDto(IReadOnlyCollection<SeckillListItemDto> 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
};
}
/// <summary>
/// 构建默认新增活动实体。
/// </summary>
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
};
}
}

View File

@@ -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;
/// <summary>
/// 秒杀映射与规则校验辅助。
/// </summary>
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<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 SeckillRulesDto DeserializeRules(string? rulesJson, long campaignId)
{
if (string.IsNullOrWhiteSpace(rulesJson))
{
throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失");
}
var payload = JsonSerializer.Deserialize<SeckillRulesDto>(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);
}
/// <summary>
/// 序列化活动规则。
/// </summary>
public static string SerializeRules(SeckillRulesDto rules)
{
return JsonSerializer.Serialize(rules, JsonOptions);
}
/// <summary>
/// 标准化并校验保存规则。
/// </summary>
public static SeckillRulesDto NormalizeRulesForSave(
string? activityType,
DateTime? startDate,
DateTime? endDate,
TimeSpan? timeStart,
TimeSpan? timeEnd,
IReadOnlyCollection<SeckillSessionRuleDto>? sessions,
IReadOnlyCollection<string>? channels,
int? perUserLimit,
bool preheatEnabled,
int? preheatHours,
IReadOnlyCollection<long>? storeIds,
IReadOnlyCollection<SeckillProductRuleDto>? 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
};
}
/// <summary>
/// 判断活动是否对可见门店可见。
/// </summary>
public static bool HasVisibleStore(SeckillRulesDto rules, IReadOnlyCollection<long> visibleStoreIds)
{
if (visibleStoreIds.Count == 0)
{
return false;
}
return rules.StoreIds.Any(visibleStoreIds.Contains);
}
/// <summary>
/// 解析活动展示状态。
/// </summary>
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;
}
/// <summary>
/// 判断是否弱化展示。
/// </summary>
public static bool IsDimmed(string displayStatus)
{
return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
}
/// <summary>
/// 计算活动实体起止时间窗口。
/// </summary>
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<SeckillSessionRuleDto> NormalizeSessions(
string activityType,
IReadOnlyCollection<SeckillSessionRuleDto>? sessions)
{
if (string.Equals(activityType, ActivityTypeTimed, StringComparison.Ordinal))
{
return [];
}
var normalized = (sessions ?? Array.Empty<SeckillSessionRuleDto>())
.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<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 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<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<SeckillProductRuleDto> NormalizeProducts(
IReadOnlyCollection<SeckillProductRuleDto>? products,
int? campaignPerUserLimit)
{
var normalized = (products ?? Array.Empty<SeckillProductRuleDto>())
.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);
}
}

View File

@@ -23,5 +23,10 @@ public enum PromotionType
/// <summary>
/// 抽奖活动。
/// </summary>
Lottery = 3
Lottery = 3,
/// <summary>
/// 秒杀活动。
/// </summary>
Seckill = 4
}