feat(marketing): implement tenant seckill backend module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m55s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m55s
This commit is contained in:
Submodule TakeoutSaaS.Docs updated: de7aefd0ff...5da102c97c
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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:00(UTC)。
|
||||
/// </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.9999999(UTC)。
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,10 @@ public enum PromotionType
|
||||
/// <summary>
|
||||
/// 抽奖活动。
|
||||
/// </summary>
|
||||
Lottery = 3
|
||||
Lottery = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动。
|
||||
/// </summary>
|
||||
Seckill = 4
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user