From dda3f96d28bc206e47b9735856fcc4ae47c4e764 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Sat, 28 Feb 2026 11:14:55 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E8=90=A5=E9=94=80?=
=?UTF-8?q?=E4=B8=AD=E5=BF=83=E4=BC=98=E6=83=A0=E5=88=B8=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Contracts/Marketing/CouponContracts.cs | 425 +
.../Controllers/MarketingCouponController.cs | 319 +
.../ChangeCouponTemplateStatusCommand.cs | 25 +
.../Commands/DeleteCouponTemplateCommand.cs | 19 +
.../Commands/SaveCouponTemplateCommand.cs | 90 +
.../App/Coupons/CouponTemplateDtoFactory.cs | 88 +
.../App/Coupons/CouponTemplateMapping.cs | 243 +
.../Coupons/Dto/CouponTemplateDetailDto.cs | 92 +
.../Coupons/Dto/CouponTemplateListItemDto.cs | 97 +
.../Dto/CouponTemplateListResultDto.cs | 32 +
.../App/Coupons/Dto/CouponTemplateStatsDto.cs | 32 +
...hangeCouponTemplateStatusCommandHandler.cs | 44 +
.../DeleteCouponTemplateCommandHandler.cs | 46 +
.../GetCouponTemplateDetailQueryHandler.cs | 38 +
.../GetCouponTemplateListQueryHandler.cs | 145 +
.../SaveCouponTemplateCommandHandler.cs | 262 +
.../Queries/GetCouponTemplateDetailQuery.cs | 20 +
.../Queries/GetCouponTemplateListQuery.cs | 40 +
.../Coupons/Entities/CouponTemplate.cs | 5 +
.../Coupons/Repositories/ICouponRepository.cs | 52 +
.../AppServiceCollectionExtensions.cs | 2 +
.../App/Persistence/TakeoutAppDbContext.cs | 1 +
.../App/Repositories/EfCouponRepository.cs | 89 +
..._AddCouponTemplatePerUserLimit.Designer.cs | 8868 +++++++++++++++++
...228023646_AddCouponTemplatePerUserLimit.cs | 29 +
.../TakeoutAppDbContextModelSnapshot.cs | 4 +
26 files changed, 11107 insertions(+)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CouponContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCouponController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Commands/ChangeCouponTemplateStatusCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Commands/DeleteCouponTemplateCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Commands/SaveCouponTemplateCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/CouponTemplateDtoFactory.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/CouponTemplateMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Dto/CouponTemplateDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Dto/CouponTemplateListItemDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Dto/CouponTemplateListResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Dto/CouponTemplateStatsDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Handlers/ChangeCouponTemplateStatusCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Handlers/DeleteCouponTemplateCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Handlers/GetCouponTemplateDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Handlers/GetCouponTemplateListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Handlers/SaveCouponTemplateCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Queries/GetCouponTemplateDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Queries/GetCouponTemplateListQuery.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/ICouponRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfCouponRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260228023646_AddCouponTemplatePerUserLimit.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260228023646_AddCouponTemplatePerUserLimit.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CouponContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CouponContracts.cs
new file mode 100644
index 0000000..86f2804
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CouponContracts.cs
@@ -0,0 +1,425 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
+
+///
+/// 优惠券列表请求。
+///
+public sealed class CouponListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 关键字。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 状态筛选(ongoing/upcoming/ended/disabled)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 券类型筛选(amount_off/discount/free_delivery)。
+ ///
+ public string? CouponType { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 优惠券详情请求。
+///
+public sealed class CouponDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 优惠券 ID。
+ ///
+ public string CouponId { get; set; } = string.Empty;
+}
+
+///
+/// 保存优惠券请求。
+///
+public sealed class SaveCouponRequest
+{
+ ///
+ /// 门店 ID(当前操作上下文)。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 优惠券 ID(编辑时传)。
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// 券名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 券类型(amount_off/discount/free_delivery)。
+ ///
+ public string CouponType { get; set; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal Value { get; set; }
+
+ ///
+ /// 使用门槛。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 发放总量。
+ ///
+ public int TotalQuantity { get; set; }
+
+ ///
+ /// 每人限领。
+ ///
+ public int? PerUserLimit { get; set; }
+
+ ///
+ /// 有效期类型(fixed/days)。
+ ///
+ public string ValidityType { get; set; } = "fixed";
+
+ ///
+ /// 固定有效期开始。
+ ///
+ public DateTime? ValidFrom { get; set; }
+
+ ///
+ /// 固定有效期结束。
+ ///
+ public DateTime? ValidTo { get; set; }
+
+ ///
+ /// 领取后有效天数。
+ ///
+ public int? RelativeValidDays { get; set; }
+
+ ///
+ /// 适用渠道(delivery/pickup/dine_in)。
+ ///
+ public List? Channels { get; set; }
+
+ ///
+ /// 门店范围模式(all/stores)。
+ ///
+ public string StoreScopeMode { get; set; } = "stores";
+
+ ///
+ /// 门店范围 ID 集合(stores 模式必传)。
+ ///
+ public List? StoreIds { get; set; }
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+}
+
+///
+/// 修改优惠券状态请求。
+///
+public sealed class ChangeCouponStatusRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 优惠券 ID。
+ ///
+ public string CouponId { get; set; } = string.Empty;
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+}
+
+///
+/// 删除优惠券请求。
+///
+public sealed class DeleteCouponRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 优惠券 ID。
+ ///
+ public string CouponId { get; set; } = string.Empty;
+}
+
+///
+/// 优惠券列表结果。
+///
+public sealed class CouponListResultResponse
+{
+ ///
+ /// 列表数据。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总条数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 统计信息。
+ ///
+ public CouponStatsResponse Stats { get; set; } = new();
+}
+
+///
+/// 优惠券列表项。
+///
+public sealed class CouponListItemResponse
+{
+ ///
+ /// 优惠券 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 券名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 券类型(amount_off/discount/free_delivery)。
+ ///
+ public string CouponType { get; set; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal Value { get; set; }
+
+ ///
+ /// 使用门槛。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 固定有效期开始。
+ ///
+ public string? ValidFrom { get; set; }
+
+ ///
+ /// 固定有效期结束。
+ ///
+ public string? ValidTo { get; set; }
+
+ ///
+ /// 领取后有效天数。
+ ///
+ public int? RelativeValidDays { get; set; }
+
+ ///
+ /// 发放总量。
+ ///
+ public int TotalQuantity { get; set; }
+
+ ///
+ /// 已领取数量。
+ ///
+ public int ClaimedQuantity { get; set; }
+
+ ///
+ /// 已核销数量。
+ ///
+ public int RedeemedQuantity { get; set; }
+
+ ///
+ /// 每人限领。
+ ///
+ public int? PerUserLimit { get; set; }
+
+ ///
+ /// 展示状态(ongoing/upcoming/ended/disabled)。
+ ///
+ public string DisplayStatus { get; set; } = "ongoing";
+
+ ///
+ /// 是否弱化展示。
+ ///
+ public bool IsDimmed { get; set; }
+
+ ///
+ /// 门店范围模式(all/stores)。
+ ///
+ public string StoreScopeMode { get; set; } = "stores";
+
+ ///
+ /// 门店 ID 列表。
+ ///
+ public List StoreIds { get; set; } = [];
+
+ ///
+ /// 渠道列表(delivery/pickup/dine_in)。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 更新时间。
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// 优惠券详情。
+///
+public sealed class CouponDetailResponse
+{
+ ///
+ /// 优惠券 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 券名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 券类型(amount_off/discount/free_delivery)。
+ ///
+ public string CouponType { get; set; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal Value { get; set; }
+
+ ///
+ /// 使用门槛。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 发放总量。
+ ///
+ public int TotalQuantity { get; set; }
+
+ ///
+ /// 已领取数量。
+ ///
+ public int ClaimedQuantity { get; set; }
+
+ ///
+ /// 每人限领。
+ ///
+ public int? PerUserLimit { get; set; }
+
+ ///
+ /// 有效期类型(fixed/days)。
+ ///
+ public string ValidityType { get; set; } = "fixed";
+
+ ///
+ /// 固定有效期开始。
+ ///
+ public string? ValidFrom { get; set; }
+
+ ///
+ /// 固定有效期结束。
+ ///
+ public string? ValidTo { get; set; }
+
+ ///
+ /// 领取后有效天数。
+ ///
+ public int? RelativeValidDays { get; set; }
+
+ ///
+ /// 渠道列表(delivery/pickup/dine_in)。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 门店范围模式(all/stores)。
+ ///
+ public string StoreScopeMode { get; set; } = "stores";
+
+ ///
+ /// 门店 ID 列表。
+ ///
+ public List StoreIds { get; set; } = [];
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+
+ ///
+ /// 更新时间。
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// 优惠券统计响应。
+///
+public sealed class CouponStatsResponse
+{
+ ///
+ /// 优惠券总数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 进行中数量。
+ ///
+ public int OngoingCount { get; set; }
+
+ ///
+ /// 已领取总数。
+ ///
+ public int ClaimedCount { get; set; }
+
+ ///
+ /// 已核销总数。
+ ///
+ public int RedeemedCount { get; set; }
+
+ ///
+ /// 核销率(百分比)。
+ ///
+ public decimal RedeemRate { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCouponController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCouponController.cs
new file mode 100644
index 0000000..c76d45f
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCouponController.cs
@@ -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;
+
+///
+/// 营销中心优惠券模板管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/marketing/coupon")]
+public sealed class MarketingCouponController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ ///
+ /// 获取优惠券列表。
+ ///
+ [HttpGet("list")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.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
+ }
+ });
+ }
+
+ ///
+ /// 获取优惠券详情。
+ ///
+ [HttpGet("detail")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Error(ErrorCodes.NotFound, "优惠券不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 保存优惠券模板(新增/编辑)。
+ ///
+ [HttpPost("save")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 修改优惠券状态。
+ ///
+ [HttpPost("status")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 删除优惠券模板。
+ ///
+ [HttpPost("delete")]
+ [ProducesResponseType(typeof(ApiResponse