feat: 完成营销中心优惠券后端模块
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m54s

This commit is contained in:
2026-02-28 11:14:55 +08:00
parent 04e76cd519
commit dda3f96d28
26 changed files with 11107 additions and 0 deletions

View File

@@ -0,0 +1,425 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 优惠券列表请求。
/// </summary>
public sealed class CouponListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 状态筛选ongoing/upcoming/ended/disabled
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 券类型筛选amount_off/discount/free_delivery
/// </summary>
public string? CouponType { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 优惠券详情请求。
/// </summary>
public sealed class CouponDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 优惠券 ID。
/// </summary>
public string CouponId { get; set; } = string.Empty;
}
/// <summary>
/// 保存优惠券请求。
/// </summary>
public sealed class SaveCouponRequest
{
/// <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>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; set; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 有效期类型fixed/days
/// </summary>
public string ValidityType { get; set; } = "fixed";
/// <summary>
/// 固定有效期开始。
/// </summary>
public DateTime? ValidFrom { get; set; }
/// <summary>
/// 固定有效期结束。
/// </summary>
public DateTime? ValidTo { get; set; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; set; }
/// <summary>
/// 适用渠道delivery/pickup/dine_in
/// </summary>
public List<string>? Channels { get; set; }
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; set; } = "stores";
/// <summary>
/// 门店范围 ID 集合stores 模式必传)。
/// </summary>
public List<string>? StoreIds { get; set; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 修改优惠券状态请求。
/// </summary>
public sealed class ChangeCouponStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 优惠券 ID。
/// </summary>
public string CouponId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 删除优惠券请求。
/// </summary>
public sealed class DeleteCouponRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 优惠券 ID。
/// </summary>
public string CouponId { get; set; } = string.Empty;
}
/// <summary>
/// 优惠券列表结果。
/// </summary>
public sealed class CouponListResultResponse
{
/// <summary>
/// 列表数据。
/// </summary>
public List<CouponListItemResponse> 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 CouponStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 优惠券列表项。
/// </summary>
public sealed class CouponListItemResponse
{
/// <summary>
/// 优惠券 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 固定有效期开始。
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定有效期结束。
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; set; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; set; }
/// <summary>
/// 已领取数量。
/// </summary>
public int ClaimedQuantity { get; set; }
/// <summary>
/// 已核销数量。
/// </summary>
public int RedeemedQuantity { get; set; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 展示状态ongoing/upcoming/ended/disabled
/// </summary>
public string DisplayStatus { get; set; } = "ongoing";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; set; }
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; set; } = "stores";
/// <summary>
/// 门店 ID 列表。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 渠道列表delivery/pickup/dine_in
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 更新时间。
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 优惠券详情。
/// </summary>
public sealed class CouponDetailResponse
{
/// <summary>
/// 优惠券 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; set; }
/// <summary>
/// 已领取数量。
/// </summary>
public int ClaimedQuantity { get; set; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary>
/// 有效期类型fixed/days
/// </summary>
public string ValidityType { get; set; } = "fixed";
/// <summary>
/// 固定有效期开始。
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定有效期结束。
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; set; }
/// <summary>
/// 渠道列表delivery/pickup/dine_in
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; set; } = "stores";
/// <summary>
/// 门店 ID 列表。
/// </summary>
public List<string> StoreIds { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 更新时间。
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 优惠券统计响应。
/// </summary>
public sealed class CouponStatsResponse
{
/// <summary>
/// 优惠券总数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; set; }
/// <summary>
/// 已领取总数。
/// </summary>
public int ClaimedCount { get; set; }
/// <summary>
/// 已核销总数。
/// </summary>
public int RedeemedCount { get; set; }
/// <summary>
/// 核销率(百分比)。
/// </summary>
public decimal RedeemRate { get; set; }
}

View File

@@ -0,0 +1,319 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Coupons.Commands;
using TakeoutSaaS.Application.App.Coupons.Dto;
using TakeoutSaaS.Application.App.Coupons.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/coupon")]
public sealed class MarketingCouponController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 获取优惠券列表。
/// </summary>
[HttpGet("list")]
[ProducesResponseType(typeof(ApiResponse<CouponListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CouponListResultResponse>> List(
[FromQuery] CouponListRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验当前门店上下文
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层查询
var result = await mediator.Send(new GetCouponTemplateListQuery
{
StoreId = storeId,
Keyword = request.Keyword,
Status = request.Status,
CouponType = request.CouponType,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
// 3. 映射响应
return ApiResponse<CouponListResultResponse>.Ok(new CouponListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize,
Stats = new CouponStatsResponse
{
TotalCount = result.Stats.TotalCount,
OngoingCount = result.Stats.OngoingCount,
ClaimedCount = result.Stats.ClaimedCount,
RedeemedCount = result.Stats.RedeemedCount,
RedeemRate = result.Stats.RedeemRate
}
});
}
/// <summary>
/// 获取优惠券详情。
/// </summary>
[HttpGet("detail")]
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CouponDetailResponse>> Detail(
[FromQuery] CouponDetailRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店访问权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 查询详情
var result = await mediator.Send(new GetCouponTemplateDetailQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
}, cancellationToken);
// 3. 处理不存在场景
if (result is null)
{
return ApiResponse<CouponDetailResponse>.Error(ErrorCodes.NotFound, "优惠券不存在");
}
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存优惠券模板(新增/编辑)。
/// </summary>
[HttpPost("save")]
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CouponDetailResponse>> Save(
[FromBody] SaveCouponRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验操作门店
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
// 2. 解析适用门店范围
var resolvedStoreIds = await ResolveStoreScopeStoreIdsAsync(
request.StoreScopeMode,
request.StoreIds,
tenantId,
merchantId,
cancellationToken);
// 3. 调用应用层保存
var result = await mediator.Send(new SaveCouponTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
Name = request.Name,
CouponType = request.CouponType,
Value = request.Value,
MinimumSpend = request.MinimumSpend,
TotalQuantity = request.TotalQuantity,
PerUserLimit = request.PerUserLimit,
ValidityType = request.ValidityType,
ValidFrom = request.ValidFrom,
ValidTo = request.ValidTo,
RelativeValidDays = request.RelativeValidDays,
Channels = request.Channels ?? [],
StoreScopeMode = NormalizeStoreScopeMode(request.StoreScopeMode),
StoreScopeStoreIds = resolvedStoreIds,
Status = request.Status
}, cancellationToken);
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 修改优惠券状态。
/// </summary>
[HttpPost("status")]
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CouponDetailResponse>> ChangeStatus(
[FromBody] ChangeCouponStatusRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店访问权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层修改状态
var result = await mediator.Send(new ChangeCouponTemplateStatusCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 删除优惠券模板。
/// </summary>
[HttpPost("delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteCouponRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店访问权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层删除
await mediator.Send(new DeleteCouponTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private async Task<IReadOnlyCollection<long>> ResolveStoreScopeStoreIdsAsync(
string? storeScopeMode,
IEnumerable<string>? storeIds,
long tenantId,
long merchantId,
CancellationToken cancellationToken)
{
// 1. 标准化 mode
var normalizedMode = NormalizeStoreScopeMode(storeScopeMode);
// 2. all 模式下展开为租户商户可见全门店
if (string.Equals(normalizedMode, "all", StringComparison.Ordinal))
{
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.Select(x => x.Id)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
// 3. stores 模式下严格校验输入门店集合
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
if (parsedStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
dbContext,
tenantId,
merchantId,
parsedStoreIds,
cancellationToken);
if (accessibleStoreIds.Count != parsedStoreIds.Count)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
}
return accessibleStoreIds.OrderBy(x => x).ToList();
}
private static string NormalizeStoreScopeMode(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (normalized is not ("all" or "stores"))
{
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
}
return normalized;
}
private static CouponListItemResponse MapListItem(CouponTemplateListItemDto source)
{
return new CouponListItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CouponType = source.CouponType,
Value = source.Value,
MinimumSpend = source.MinimumSpend,
ValidFrom = ToDateOnly(source.ValidFrom),
ValidTo = ToDateOnly(source.ValidTo),
RelativeValidDays = source.RelativeValidDays,
TotalQuantity = source.TotalQuantity,
ClaimedQuantity = source.ClaimedQuantity,
RedeemedQuantity = source.RedeemedQuantity,
PerUserLimit = source.PerUserLimit,
DisplayStatus = source.DisplayStatus,
IsDimmed = source.IsDimmed,
StoreScopeMode = source.StoreScopeMode,
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
Channels = source.Channels.ToList(),
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static CouponDetailResponse MapDetail(CouponTemplateDetailDto source)
{
return new CouponDetailResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CouponType = source.CouponType,
Value = source.Value,
MinimumSpend = source.MinimumSpend,
TotalQuantity = source.TotalQuantity,
ClaimedQuantity = source.ClaimedQuantity,
PerUserLimit = source.PerUserLimit,
ValidityType = source.ValidityType,
ValidFrom = ToDateOnly(source.ValidFrom),
ValidTo = ToDateOnly(source.ValidTo),
RelativeValidDays = source.RelativeValidDays,
Channels = source.Channels.ToList(),
StoreScopeMode = source.StoreScopeMode,
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
Status = source.Status,
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static string? ToDateOnly(DateTime? value)
{
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Commands;
/// <summary>
/// 修改优惠券模板状态命令。
/// </summary>
public sealed class ChangeCouponTemplateStatusCommand : IRequest<CouponTemplateDetailDto>
{
/// <summary>
/// 当前筛选门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 编辑态状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.Commands;
/// <summary>
/// 删除优惠券模板命令。
/// </summary>
public sealed class DeleteCouponTemplateCommand : IRequest<Unit>
{
/// <summary>
/// 当前筛选门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,90 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Commands;
/// <summary>
/// 保存优惠券模板命令。
/// </summary>
public sealed class SaveCouponTemplateCommand : IRequest<CouponTemplateDetailDto>
{
/// <summary>
/// 当前操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 模板 ID编辑时传
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; init; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; init; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 有效期类型fixed/days
/// </summary>
public string ValidityType { get; init; } = "fixed";
/// <summary>
/// 固定有效期开始。
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定有效期结束。
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; init; }
/// <summary>
/// 适用渠道集合。
/// </summary>
public IReadOnlyCollection<string> Channels { get; init; } = [];
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; init; } = "stores";
/// <summary>
/// 门店范围 ID 集合(已完成可访问性校验)。
/// </summary>
public IReadOnlyCollection<long> StoreScopeStoreIds { get; init; } = [];
/// <summary>
/// 编辑态状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,88 @@
using TakeoutSaaS.Application.App.Coupons.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
namespace TakeoutSaaS.Application.App.Coupons;
/// <summary>
/// 优惠券模板 DTO 映射工厂。
/// </summary>
internal static class CouponTemplateDtoFactory
{
public static CouponTemplateListItemDto ToListItemDto(
CouponTemplate template,
CouponStoreScopeModel storeScope,
IReadOnlyList<string> channels,
int redeemedQuantity,
DateTime nowUtc)
{
var displayStatus = CouponTemplateMapping.ResolveDisplayStatus(template, nowUtc);
return new CouponTemplateListItemDto
{
Id = template.Id,
Name = template.Name,
CouponType = CouponTemplateMapping.ToCouponTypeText(template.CouponType),
Value = template.Value,
MinimumSpend = template.MinimumSpend,
ValidFrom = template.ValidFrom,
ValidTo = template.ValidTo,
RelativeValidDays = template.RelativeValidDays,
TotalQuantity = template.TotalQuantity ?? 0,
ClaimedQuantity = template.ClaimedQuantity,
RedeemedQuantity = redeemedQuantity,
PerUserLimit = template.PerUserLimit,
DisplayStatus = displayStatus,
IsDimmed = CouponTemplateMapping.IsDimmedDisplayStatus(displayStatus),
StoreScopeMode = storeScope.Mode,
StoreIds = storeScope.StoreIds,
Channels = channels,
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static CouponTemplateDetailDto ToDetailDto(
CouponTemplate template,
CouponStoreScopeModel storeScope,
IReadOnlyList<string> channels)
{
return new CouponTemplateDetailDto
{
Id = template.Id,
Name = template.Name,
CouponType = CouponTemplateMapping.ToCouponTypeText(template.CouponType),
Value = template.Value,
MinimumSpend = template.MinimumSpend,
TotalQuantity = template.TotalQuantity ?? 0,
ClaimedQuantity = template.ClaimedQuantity,
PerUserLimit = template.PerUserLimit,
ValidityType = CouponTemplateMapping.ResolveValidityType(template),
ValidFrom = template.ValidFrom,
ValidTo = template.ValidTo,
RelativeValidDays = template.RelativeValidDays,
Channels = channels,
StoreScopeMode = storeScope.Mode,
StoreIds = storeScope.StoreIds,
Status = CouponTemplateMapping.ToEditorStatus(template.Status),
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static CouponTemplateStatsDto ToStatsDto(
int totalCount,
int ongoingCount,
int claimedCount,
int redeemedCount)
{
var redeemRate = claimedCount <= 0
? 0m
: Math.Round((decimal)redeemedCount * 100m / claimedCount, 1, MidpointRounding.AwayFromZero);
return new CouponTemplateStatsDto
{
TotalCount = totalCount,
OngoingCount = ongoingCount,
ClaimedCount = claimedCount,
RedeemedCount = redeemedCount,
RedeemRate = redeemRate
};
}
}

View File

@@ -0,0 +1,243 @@
using System.Text.Json;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Coupons;
/// <summary>
/// 优惠券模板映射辅助。
/// </summary>
internal static class CouponTemplateMapping
{
private const string StatusDisabled = "disabled";
private const string StatusEnded = "ended";
private const string StatusOngoing = "ongoing";
private const string StatusUpcoming = "upcoming";
private static readonly HashSet<string> AllowedChannels = ["delivery", "pickup", "dine_in"];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static bool TryParseCouponType(string? value, out CouponType couponType)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "amount_off":
couponType = CouponType.AmountOff;
return true;
case "discount":
couponType = CouponType.Percentage;
return true;
case "free_delivery":
couponType = CouponType.DeliveryFee;
return true;
default:
couponType = CouponType.AmountOff;
return false;
}
}
public static string ToCouponTypeText(CouponType value)
{
return value switch
{
CouponType.AmountOff => "amount_off",
CouponType.Percentage => "discount",
CouponType.DeliveryFee => "free_delivery",
_ => throw new BusinessException(ErrorCodes.BadRequest, "存在不支持的券类型数据")
};
}
public static bool TryParseEditorStatus(string? value, out CouponTemplateStatus status)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "enabled":
status = CouponTemplateStatus.Active;
return true;
case "disabled":
status = CouponTemplateStatus.Archived;
return true;
default:
status = CouponTemplateStatus.Archived;
return false;
}
}
public static string ToEditorStatus(CouponTemplateStatus value)
{
return value == CouponTemplateStatus.Active ? "enabled" : "disabled";
}
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 (candidate is StatusOngoing or StatusUpcoming or StatusEnded or StatusDisabled)
{
normalized = candidate;
return true;
}
normalized = null;
return false;
}
public static string ResolveDisplayStatus(CouponTemplate template, DateTime nowUtc)
{
if (template.Status != CouponTemplateStatus.Active)
{
return StatusDisabled;
}
if (template.ValidFrom.HasValue && nowUtc < template.ValidFrom.Value)
{
return StatusUpcoming;
}
if (template.ValidTo.HasValue && nowUtc > template.ValidTo.Value)
{
return StatusEnded;
}
return StatusOngoing;
}
public static bool IsDimmedDisplayStatus(string displayStatus)
{
return string.Equals(displayStatus, StatusDisabled, StringComparison.OrdinalIgnoreCase)
|| string.Equals(displayStatus, StatusEnded, StringComparison.OrdinalIgnoreCase);
}
public static string ResolveValidityType(CouponTemplate template)
{
return template.RelativeValidDays.HasValue && template.RelativeValidDays.Value > 0
? "days"
: "fixed";
}
public static IReadOnlyList<string> DeserializeChannels(string? channelsJson, long templateId)
{
if (string.IsNullOrWhiteSpace(channelsJson))
{
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置缺失");
}
var channels = JsonSerializer.Deserialize<List<string>>(channelsJson, JsonOptions)
?? throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置异常");
var normalized = channels
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.ToList();
if (normalized.Count == 0 || normalized.Any(item => !AllowedChannels.Contains(item)))
{
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置异常");
}
return normalized;
}
public static IReadOnlyList<string> NormalizeChannelsForSave(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;
}
public static CouponStoreScopeModel DeserializeStoreScope(string? storeScopeJson, long templateId)
{
if (string.IsNullOrWhiteSpace(storeScopeJson))
{
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置缺失");
}
var payload = JsonSerializer.Deserialize<CouponStoreScopePayload>(storeScopeJson, JsonOptions)
?? throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
var mode = (payload.Mode ?? string.Empty).Trim().ToLowerInvariant();
var storeIds = (payload.StoreIds ?? [])
.Where(id => id > 0)
.Distinct()
.ToList();
if (mode is not ("all" or "stores"))
{
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
}
if (storeIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
}
return new CouponStoreScopeModel(mode, storeIds);
}
public static string SerializeStoreScope(string mode, IReadOnlyCollection<long> storeIds)
{
var normalizedMode = (mode ?? string.Empty).Trim().ToLowerInvariant();
if (normalizedMode is not ("all" or "stores"))
{
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 非法");
}
var normalizedStoreIds = storeIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (normalizedStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
return JsonSerializer.Serialize(new CouponStoreScopePayload
{
Mode = normalizedMode,
StoreIds = normalizedStoreIds
}, JsonOptions);
}
public static string SerializeChannels(IReadOnlyCollection<string> channels)
{
return JsonSerializer.Serialize(channels, JsonOptions);
}
private sealed class CouponStoreScopePayload
{
public string Mode { get; set; } = "stores";
public List<long> StoreIds { get; set; } = [];
}
}
/// <summary>
/// 优惠券门店范围快照。
/// </summary>
internal sealed record CouponStoreScopeModel(string Mode, IReadOnlyList<long> StoreIds);

View File

@@ -0,0 +1,92 @@
namespace TakeoutSaaS.Application.App.Coupons.Dto;
/// <summary>
/// 优惠券模板详情 DTO。
/// </summary>
public sealed class CouponTemplateDetailDto
{
/// <summary>
/// 模板 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; init; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; init; }
/// <summary>
/// 已领取数量。
/// </summary>
public int ClaimedQuantity { get; init; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 有效期类型fixed/days
/// </summary>
public string ValidityType { get; init; } = "fixed";
/// <summary>
/// 固定有效期开始。
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定有效期结束。
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; init; }
/// <summary>
/// 渠道列表delivery/pickup/dine_in
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; init; } = "stores";
/// <summary>
/// 门店范围 ID 列表。
/// </summary>
public IReadOnlyList<long> StoreIds { get; init; } = [];
/// <summary>
/// 编辑状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,97 @@
namespace TakeoutSaaS.Application.App.Coupons.Dto;
/// <summary>
/// 优惠券模板列表项 DTO。
/// </summary>
public sealed class CouponTemplateListItemDto
{
/// <summary>
/// 模板 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 券类型amount_off/discount/free_delivery
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; init; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 固定时间开始。
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定时间结束。
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 领取后有效天数。
/// </summary>
public int? RelativeValidDays { get; init; }
/// <summary>
/// 发放总量。
/// </summary>
public int TotalQuantity { get; init; }
/// <summary>
/// 已领取数量。
/// </summary>
public int ClaimedQuantity { get; init; }
/// <summary>
/// 已核销数量。
/// </summary>
public int RedeemedQuantity { get; init; }
/// <summary>
/// 每人限领。
/// </summary>
public int? PerUserLimit { get; init; }
/// <summary>
/// 展示状态ongoing/upcoming/ended/disabled
/// </summary>
public string DisplayStatus { get; init; } = "ongoing";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; init; } = "stores";
/// <summary>
/// 门店范围 ID 列表。
/// </summary>
public IReadOnlyList<long> StoreIds { get; init; } = [];
/// <summary>
/// 渠道列表delivery/pickup/dine_in
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.Dto;
/// <summary>
/// 优惠券模板列表结果 DTO。
/// </summary>
public sealed class CouponTemplateListResultDto
{
/// <summary>
/// 列表数据。
/// </summary>
public IReadOnlyList<CouponTemplateListItemDto> 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 CouponTemplateStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.Dto;
/// <summary>
/// 优惠券统计 DTO。
/// </summary>
public sealed class CouponTemplateStatsDto
{
/// <summary>
/// 优惠券总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; init; }
/// <summary>
/// 已领取总数。
/// </summary>
public int ClaimedCount { get; init; }
/// <summary>
/// 已核销总数。
/// </summary>
public int RedeemedCount { get; init; }
/// <summary>
/// 核销率(百分比)。
/// </summary>
public decimal RedeemRate { get; init; }
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Commands;
using TakeoutSaaS.Application.App.Coupons.Dto;
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.Handlers;
/// <summary>
/// 修改优惠券模板状态命令处理器。
/// </summary>
public sealed class ChangeCouponTemplateStatusCommandHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeCouponTemplateStatusCommand, CouponTemplateDetailDto>
{
/// <inheritdoc />
public async Task<CouponTemplateDetailDto> Handle(ChangeCouponTemplateStatusCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
{
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
}
if (!CouponTemplateMapping.TryParseEditorStatus(request.Status, out var status))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
template.Status = status;
await couponRepository.UpdateTemplateAsync(template, cancellationToken);
await couponRepository.SaveChangesAsync(cancellationToken);
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
return CouponTemplateDtoFactory.ToDetailDto(template, scope, channels);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Commands;
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.Handlers;
/// <summary>
/// 删除优惠券模板命令处理器。
/// </summary>
public sealed class DeleteCouponTemplateCommandHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteCouponTemplateCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(DeleteCouponTemplateCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
{
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
}
if (template.ClaimedQuantity > 0)
{
throw new BusinessException(ErrorCodes.Conflict, "优惠券已被领取,禁止删除");
}
var issuedCount = await couponRepository.CountIssuedCouponsByTemplateIdAsync(tenantId, template.Id, cancellationToken);
if (issuedCount > 0)
{
throw new BusinessException(ErrorCodes.Conflict, "优惠券已被领取,禁止删除");
}
await couponRepository.DeleteTemplateAsync(template, cancellationToken);
await couponRepository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,38 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
using TakeoutSaaS.Application.App.Coupons.Queries;
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.Handlers;
/// <summary>
/// 优惠券模板详情查询处理器。
/// </summary>
public sealed class GetCouponTemplateDetailQueryHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetCouponTemplateDetailQuery, CouponTemplateDetailDto?>
{
/// <inheritdoc />
public async Task<CouponTemplateDetailDto?> Handle(GetCouponTemplateDetailQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken);
if (template is null)
{
return null;
}
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
{
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
}
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
return CouponTemplateDtoFactory.ToDetailDto(template, scope, channels);
}
}

View File

@@ -0,0 +1,145 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
using TakeoutSaaS.Application.App.Coupons.Queries;
using TakeoutSaaS.Domain.Coupons.Entities;
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.Handlers;
/// <summary>
/// 优惠券模板列表查询处理器。
/// </summary>
public sealed class GetCouponTemplateListQueryHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetCouponTemplateListQuery, CouponTemplateListResultDto>
{
/// <inheritdoc />
public async Task<CouponTemplateListResultDto> Handle(GetCouponTemplateListQuery request, CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
if (!CouponTemplateMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
CouponType? couponTypeFilter = null;
if (!string.IsNullOrWhiteSpace(request.CouponType))
{
if (!CouponTemplateMapping.TryParseCouponType(request.CouponType, out var parsedType))
{
throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法");
}
couponTypeFilter = parsedType;
}
var tenantId = tenantProvider.GetCurrentTenantId();
var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
if (templates.Count == 0)
{
return new CouponTemplateListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = CouponTemplateDtoFactory.ToStatsDto(0, 0, 0, 0)
};
}
var nowUtc = DateTime.UtcNow;
var visibleTemplates = new List<ResolvedTemplateItem>(templates.Count);
foreach (var template in templates)
{
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
{
continue;
}
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
visibleTemplates.Add(new ResolvedTemplateItem(
template,
scope,
channels,
CouponTemplateMapping.ResolveDisplayStatus(template, nowUtc)));
}
if (visibleTemplates.Count == 0)
{
return new CouponTemplateListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = CouponTemplateDtoFactory.ToStatsDto(0, 0, 0, 0)
};
}
var templateIds = visibleTemplates.Select(item => item.Template.Id).ToArray();
var redeemedMap = await couponRepository.CountRedeemedCouponsByTemplateIdsAsync(tenantId, templateIds, cancellationToken);
var stats = CouponTemplateDtoFactory.ToStatsDto(
visibleTemplates.Count,
visibleTemplates.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)),
visibleTemplates.Sum(item => item.Template.ClaimedQuantity),
visibleTemplates.Sum(item => redeemedMap.GetValueOrDefault(item.Template.Id)));
IEnumerable<ResolvedTemplateItem> filtered = visibleTemplates;
var normalizedKeyword = request.Keyword?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
filtered = filtered.Where(item => item.Template.Name.ToLowerInvariant().Contains(normalizedKeyword));
}
if (couponTypeFilter.HasValue)
{
filtered = filtered.Where(item => item.Template.CouponType == couponTypeFilter.Value);
}
if (!string.IsNullOrWhiteSpace(normalizedStatus))
{
filtered = filtered.Where(item => string.Equals(item.DisplayStatus, normalizedStatus, StringComparison.Ordinal));
}
var ordered = filtered
.OrderByDescending(item => item.Template.UpdatedAt ?? item.Template.CreatedAt)
.ThenByDescending(item => item.Template.Id)
.ToList();
var total = ordered.Count;
var paged = ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(item => CouponTemplateDtoFactory.ToListItemDto(
item.Template,
item.Scope,
item.Channels,
redeemedMap.GetValueOrDefault(item.Template.Id),
nowUtc))
.ToList();
return new CouponTemplateListResultDto
{
Items = paged,
TotalCount = total,
Page = page,
PageSize = pageSize,
Stats = stats
};
}
private sealed record ResolvedTemplateItem(
CouponTemplate Template,
CouponStoreScopeModel Scope,
IReadOnlyList<string> Channels,
string DisplayStatus);
}

View File

@@ -0,0 +1,262 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Commands;
using TakeoutSaaS.Application.App.Coupons.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
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.Handlers;
/// <summary>
/// 保存优惠券模板命令处理器。
/// </summary>
public sealed class SaveCouponTemplateCommandHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveCouponTemplateCommand, CouponTemplateDetailDto>
{
/// <inheritdoc />
public async Task<CouponTemplateDetailDto> Handle(SaveCouponTemplateCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
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 (!CouponTemplateMapping.TryParseCouponType(request.CouponType, out var couponType))
{
throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法");
}
if (!CouponTemplateMapping.TryParseEditorStatus(request.Status, out var status))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
if (request.TotalQuantity <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "发放总量必须大于 0");
}
if (request.PerUserLimit.HasValue && request.PerUserLimit.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "每人限领必须大于 0");
}
var normalizedChannels = CouponTemplateMapping.NormalizeChannelsForSave(request.Channels);
var storeScopeJson = CouponTemplateMapping.SerializeStoreScope(request.StoreScopeMode, request.StoreScopeStoreIds);
var channelsJson = CouponTemplateMapping.SerializeChannels(normalizedChannels);
var (value, minimumSpend, validFrom, validTo, relativeValidDays) = NormalizeBusinessFields(request, couponType);
CouponTemplate template;
if (!request.TemplateId.HasValue)
{
template = new CouponTemplate
{
Name = normalizedName,
CouponType = couponType,
Value = value,
MinimumSpend = minimumSpend,
ValidFrom = validFrom,
ValidTo = validTo,
RelativeValidDays = relativeValidDays,
TotalQuantity = request.TotalQuantity,
ClaimedQuantity = 0,
PerUserLimit = request.PerUserLimit,
StoreScopeJson = storeScopeJson,
ProductScopeJson = null,
ChannelsJson = channelsJson,
AllowStack = false,
Status = status,
Description = null
};
await couponRepository.AddTemplateAsync(template, cancellationToken);
await couponRepository.SaveChangesAsync(cancellationToken);
}
else
{
template = await couponRepository.FindTemplateByIdAsync(request.TemplateId.Value, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
var existingScope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
if (!existingScope.StoreIds.Contains(request.StoreId) && !string.Equals(existingScope.Mode, "all", StringComparison.Ordinal))
{
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
}
if (request.TotalQuantity < template.ClaimedQuantity)
{
throw new BusinessException(ErrorCodes.BadRequest, "发放总量不能小于已领取数量");
}
if (template.TotalQuantity.HasValue && request.TotalQuantity < template.TotalQuantity.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "发放总量不可减少,只能增加");
}
template.Name = normalizedName;
template.CouponType = couponType;
template.Value = value;
template.MinimumSpend = minimumSpend;
template.ValidFrom = validFrom;
template.ValidTo = validTo;
template.RelativeValidDays = relativeValidDays;
template.TotalQuantity = request.TotalQuantity;
template.PerUserLimit = request.PerUserLimit;
template.StoreScopeJson = storeScopeJson;
template.ChannelsJson = channelsJson;
template.Status = status;
await couponRepository.UpdateTemplateAsync(template, cancellationToken);
await couponRepository.SaveChangesAsync(cancellationToken);
}
var storeScope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
return CouponTemplateDtoFactory.ToDetailDto(template, storeScope, channels);
}
private static (decimal Value, decimal? MinimumSpend, DateTime? ValidFrom, DateTime? ValidTo, int? RelativeValidDays)
NormalizeBusinessFields(SaveCouponTemplateCommand request, CouponType couponType)
{
var value = couponType switch
{
CouponType.AmountOff => NormalizeAmountOffValue(request.Value),
CouponType.Percentage => NormalizeDiscountValue(request.Value),
CouponType.DeliveryFee => 0m,
_ => throw new BusinessException(ErrorCodes.BadRequest, "不支持的券类型")
};
var minimumSpend = couponType switch
{
CouponType.AmountOff => NormalizeRequiredMinimumSpend(request.MinimumSpend),
CouponType.Percentage => NormalizeOptionalMinimumSpend(request.MinimumSpend),
CouponType.DeliveryFee => null,
_ => null
};
if (couponType == CouponType.DeliveryFee && request.MinimumSpend.HasValue && request.MinimumSpend.Value > 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "免配送费券不支持设置使用门槛");
}
var validityType = (request.ValidityType ?? string.Empty).Trim().ToLowerInvariant();
return validityType switch
{
"fixed" => NormalizeFixedValidity(value, minimumSpend, request.ValidFrom, request.ValidTo),
"days" => (
value,
minimumSpend,
null,
null,
NormalizeRelativeValidDays(request.RelativeValidDays)),
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
};
}
private static decimal NormalizeAmountOffValue(decimal value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减券面额必须大于 0");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizeDiscountValue(decimal value)
{
if (value <= 0 || value >= 10)
{
throw new BusinessException(ErrorCodes.BadRequest, "折扣券面额必须在 0 到 10 之间");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizeRequiredMinimumSpend(decimal? value)
{
if (!value.HasValue || value.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减券必须设置使用门槛");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
private static decimal? NormalizeOptionalMinimumSpend(decimal? value)
{
if (!value.HasValue)
{
return null;
}
if (value.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "使用门槛必须大于 0");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
private static DateTime NormalizeDateStart(DateTime? value, string fieldName)
{
if (!value.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
var utc = value.Value.Kind == DateTimeKind.Utc
? value.Value
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
return utc.Date;
}
private static DateTime NormalizeDateEnd(DateTime? value, string fieldName)
{
if (!value.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
var utc = value.Value.Kind == DateTimeKind.Utc
? value.Value
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
return utc.Date.AddDays(1).AddTicks(-1);
}
private static int NormalizeRelativeValidDays(int? value)
{
if (!value.HasValue || value.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "领取后有效天数必须大于 0");
}
return value.Value;
}
private static (decimal Value, decimal? MinimumSpend, DateTime? ValidFrom, DateTime? ValidTo, int? RelativeValidDays)
NormalizeFixedValidity(decimal value, decimal? minimumSpend, DateTime? validFrom, DateTime? validTo)
{
var normalizedStart = NormalizeDateStart(validFrom, "validFrom");
var normalizedEnd = NormalizeDateEnd(validTo, "validTo");
if (normalizedStart > normalizedEnd)
{
throw new BusinessException(ErrorCodes.BadRequest, "固定有效期开始时间不能晚于结束时间");
}
return (value, minimumSpend, normalizedStart, normalizedEnd, null);
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Queries;
/// <summary>
/// 查询优惠券模板详情。
/// </summary>
public sealed class GetCouponTemplateDetailQuery : IRequest<CouponTemplateDetailDto?>
{
/// <summary>
/// 当前筛选门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Queries;
/// <summary>
/// 查询优惠券模板列表。
/// </summary>
public sealed class GetCouponTemplateListQuery : IRequest<CouponTemplateListResultDto>
{
/// <summary>
/// 当前筛选门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 展示状态筛选ongoing/upcoming/ended/disabled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 券类型筛选amount_off/discount/free_delivery
/// </summary>
public string? CouponType { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -53,6 +53,11 @@ public sealed class CouponTemplate : MultiTenantEntityBase
/// </summary> /// </summary>
public int? TotalQuantity { get; set; } public int? TotalQuantity { get; set; }
/// <summary>
/// 每位用户可领取上限。
/// </summary>
public int? PerUserLimit { get; set; }
/// <summary> /// <summary>
/// 已领取数量。 /// 已领取数量。
/// </summary> /// </summary>

View File

@@ -0,0 +1,52 @@
using TakeoutSaaS.Domain.Coupons.Entities;
namespace TakeoutSaaS.Domain.Coupons.Repositories;
/// <summary>
/// 优惠券聚合仓储契约。
/// </summary>
public interface ICouponRepository
{
/// <summary>
/// 查询租户下的券模板列表。
/// </summary>
Task<IReadOnlyList<CouponTemplate>> GetTemplatesAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询券模板。
/// </summary>
Task<CouponTemplate?> FindTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增券模板。
/// </summary>
Task AddTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 更新券模板。
/// </summary>
Task UpdateTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 删除券模板。
/// </summary>
Task DeleteTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 统计模板下已发放券总数。
/// </summary>
Task<int> CountIssuedCouponsByTemplateIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default);
/// <summary>
/// 按模板统计已核销券数量。
/// </summary>
Task<Dictionary<long, int>> CountRedeemedCouponsByTemplateIdsAsync(
long tenantId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories; using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories; using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories;
@@ -44,6 +45,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>(); services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
services.AddScoped<IStoreRepository, EfStoreRepository>(); services.AddScoped<IStoreRepository, EfStoreRepository>();
services.AddScoped<IProductRepository, EfProductRepository>(); services.AddScoped<IProductRepository, EfProductRepository>();
services.AddScoped<ICouponRepository, EfCouponRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>(); services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>(); services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>(); services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -1590,6 +1590,7 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.CouponType).HasConversion<int>(); builder.Property(x => x.CouponType).HasConversion<int>();
builder.Property(x => x.Description).HasMaxLength(512); builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.TotalQuantity); builder.Property(x => x.TotalQuantity);
builder.Property(x => x.PerUserLimit);
builder.Property(x => x.StoreScopeJson).HasColumnType("text"); builder.Property(x => x.StoreScopeJson).HasColumnType("text");
builder.Property(x => x.ProductScopeJson).HasColumnType("text"); builder.Property(x => x.ProductScopeJson).HasColumnType("text");
builder.Property(x => x.ChannelsJson).HasColumnType("text"); builder.Property(x => x.ChannelsJson).HasColumnType("text");

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 优惠券聚合的 EF Core 仓储实现。
/// </summary>
public sealed class EfCouponRepository(TakeoutAppDbContext context) : ICouponRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<CouponTemplate>> GetTemplatesAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.CouponTemplates
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
.ThenByDescending(x => x.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<CouponTemplate?> FindTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default)
{
return context.CouponTemplates
.Where(x => x.TenantId == tenantId && x.Id == templateId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
{
return context.CouponTemplates.AddAsync(template, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
{
context.CouponTemplates.Update(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
{
context.CouponTemplates.Remove(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountIssuedCouponsByTemplateIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default)
{
return context.Coupons
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.CouponTemplateId == templateId)
.CountAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<long, int>> CountRedeemedCouponsByTemplateIdsAsync(
long tenantId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default)
{
if (templateIds.Count == 0)
{
return [];
}
return await context.Coupons
.AsNoTracking()
.Where(x =>
x.TenantId == tenantId &&
templateIds.Contains(x.CouponTemplateId) &&
x.Status == CouponStatus.Redeemed)
.GroupBy(x => x.CouponTemplateId)
.Select(group => new { CouponTemplateId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.CouponTemplateId, item => item.Count, cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCouponTemplatePerUserLimit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PerUserLimit",
table: "coupon_templates",
type: "integer",
nullable: true,
comment: "每位用户可领取上限。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PerUserLimit",
table: "coupon_templates");
}
}
}

View File

@@ -548,6 +548,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasComment("模板名称。"); .HasComment("模板名称。");
b.Property<int?>("PerUserLimit")
.HasColumnType("integer")
.HasComment("每位用户可领取上限。");
b.Property<string>("ProductScopeJson") b.Property<string>("ProductScopeJson")
.HasColumnType("text") .HasColumnType("text")
.HasComment("适用品类或商品范围JSON。"); .HasComment("适用品类或商品范围JSON。");