From 3b3bdcee71eaadbf2d7c737842dd45b52c21d297 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Mon, 2 Mar 2026 21:43:09 +0800
Subject: [PATCH] feat: implement marketing punch card backend module
---
TakeoutSaaS.Docs | 2 +-
.../Contracts/Marketing/PunchCardContracts.cs | 809 ++
.../MarketingPunchCardController.cs | 402 +
.../ChangePunchCardTemplateStatusCommand.cs | 25 +
.../DeletePunchCardTemplateCommand.cs | 19 +
.../Commands/SavePunchCardTemplateCommand.cs | 130 +
.../WritePunchCardUsageRecordCommand.cs | 60 +
.../PunchCard/Dto/PunchCardDetailDto.cs | 137 +
.../PunchCard/Dto/PunchCardListItemDto.cs | 92 +
.../PunchCard/Dto/PunchCardListResultDto.cs | 32 +
.../PunchCard/Dto/PunchCardScopeDto.cs | 27 +
.../PunchCard/Dto/PunchCardStatsDto.cs | 27 +
.../Dto/PunchCardTemplateOptionDto.cs | 17 +
.../PunchCard/Dto/PunchCardUsageRecordDto.cs | 77 +
.../Dto/PunchCardUsageRecordExportDto.cs | 22 +
.../Dto/PunchCardUsageRecordListResultDto.cs | 37 +
.../PunchCard/Dto/PunchCardUsageStatsDto.cs | 22 +
...gePunchCardTemplateStatusCommandHandler.cs | 51 +
.../DeletePunchCardTemplateCommandHandler.cs | 43 +
...portPunchCardUsageRecordCsvQueryHandler.cs | 128 +
.../GetPunchCardTemplateDetailQueryHandler.cs | 47 +
.../GetPunchCardTemplateListQueryHandler.cs | 67 +
...GetPunchCardUsageRecordListQueryHandler.cs | 104 +
.../SavePunchCardTemplateCommandHandler.cs | 158 +
...WritePunchCardUsageRecordCommandHandler.cs | 160 +
.../Coupons/PunchCard/PunchCardDtoFactory.cs | 210 +
.../App/Coupons/PunchCard/PunchCardMapping.cs | 546 +
.../ExportPunchCardUsageRecordCsvQuery.cs | 30 +
.../GetPunchCardTemplateDetailQuery.cs | 20 +
.../Queries/GetPunchCardTemplateListQuery.cs | 35 +
.../GetPunchCardUsageRecordListQuery.cs | 40 +
.../Coupons/Entities/PunchCardInstance.cs | 65 +
.../Coupons/Entities/PunchCardTemplate.cs | 130 +
.../Coupons/Entities/PunchCardUsageRecord.cs | 60 +
.../Coupons/Enums/PunchCardExpireStrategy.cs | 17 +
.../Coupons/Enums/PunchCardInstanceStatus.cs | 27 +
.../Coupons/Enums/PunchCardScopeType.cs | 27 +
.../Coupons/Enums/PunchCardStatus.cs | 17 +
.../Coupons/Enums/PunchCardUsageMode.cs | 17 +
.../Enums/PunchCardUsageRecordStatus.cs | 27 +
.../Coupons/Enums/PunchCardValidityType.cs | 17 +
.../Repositories/IPunchCardRepository.cs | 247 +
.../AppServiceCollectionExtensions.cs | 1 +
.../App/Persistence/TakeoutAppDbContext.cs | 86 +
.../App/Repositories/EfPunchCardRepository.cs | 469 +
...60302125930_AddPunchCardModule.Designer.cs | 9547 +++++++++++++++++
.../20260302125930_AddPunchCardModule.cs | 177 +
.../TakeoutAppDbContextModelSnapshot.cs | 357 +
48 files changed, 14863 insertions(+), 1 deletion(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/PunchCardContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingPunchCardController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Commands/ChangePunchCardTemplateStatusCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Commands/DeletePunchCardTemplateCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Commands/SavePunchCardTemplateCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Commands/WritePunchCardUsageRecordCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardListItemDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardListResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardScopeDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardStatsDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardTemplateOptionDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardUsageRecordDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardUsageRecordExportDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardUsageRecordListResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Dto/PunchCardUsageStatsDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/ChangePunchCardTemplateStatusCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/DeletePunchCardTemplateCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/ExportPunchCardUsageRecordCsvQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/GetPunchCardTemplateDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/GetPunchCardTemplateListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/GetPunchCardUsageRecordListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/SavePunchCardTemplateCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Handlers/WritePunchCardUsageRecordCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/PunchCardDtoFactory.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/PunchCardMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Queries/ExportPunchCardUsageRecordCsvQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Queries/GetPunchCardTemplateDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Queries/GetPunchCardTemplateListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/PunchCard/Queries/GetPunchCardUsageRecordListQuery.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PunchCardInstance.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PunchCardTemplate.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PunchCardUsageRecord.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardExpireStrategy.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardInstanceStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardScopeType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardUsageMode.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardUsageRecordStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PunchCardValidityType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPunchCardRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPunchCardRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302125930_AddPunchCardModule.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302125930_AddPunchCardModule.cs
diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index 0941503..315fec7 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit 094150312469f156198500db5bce4581ea026cfa
+Subproject commit 315fec77b6a226f74a4d8bbd8bd264179135b9b6
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/PunchCardContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/PunchCardContracts.cs
new file mode 100644
index 0000000..5f73d44
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/PunchCardContracts.cs
@@ -0,0 +1,809 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
+
+///
+/// 次卡列表查询请求。
+///
+public sealed class PunchCardListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 名称关键字。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 状态筛选(enabled/disabled)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 4;
+}
+
+///
+/// 次卡详情请求。
+///
+public sealed class PunchCardDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡 ID。
+ ///
+ public string PunchCardId { get; set; } = string.Empty;
+}
+
+///
+/// 保存次卡请求。
+///
+public sealed class SavePunchCardRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡 ID(编辑时传)。
+ ///
+ public string? Id { get; set; }
+
+ ///
+ /// 次卡名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 封面图地址。
+ ///
+ public string? CoverImageUrl { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal SalePrice { get; set; }
+
+ ///
+ /// 原价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 总次数。
+ ///
+ public int TotalTimes { get; set; }
+
+ ///
+ /// 有效期类型(days/range)。
+ ///
+ public string ValidityType { get; set; } = "days";
+
+ ///
+ /// 固定天数。
+ ///
+ public int? ValidityDays { get; set; }
+
+ ///
+ /// 固定开始日期(yyyy-MM-dd)。
+ ///
+ public string? ValidFrom { get; set; }
+
+ ///
+ /// 固定结束日期(yyyy-MM-dd)。
+ ///
+ public string? ValidTo { get; set; }
+
+ ///
+ /// 范围类型(all/category/tag/product)。
+ ///
+ public string ScopeType { get; set; } = "all";
+
+ ///
+ /// 指定分类 ID。
+ ///
+ public List ScopeCategoryIds { get; set; } = [];
+
+ ///
+ /// 指定标签 ID。
+ ///
+ public List ScopeTagIds { get; set; } = [];
+
+ ///
+ /// 指定商品 ID。
+ ///
+ public List ScopeProductIds { get; set; } = [];
+
+ ///
+ /// 使用模式(free/cap)。
+ ///
+ public string UsageMode { get; set; } = "free";
+
+ ///
+ /// 单次上限金额。
+ ///
+ public decimal? UsageCapAmount { get; set; }
+
+ ///
+ /// 每日限用次数。
+ ///
+ public int? DailyLimit { get; set; }
+
+ ///
+ /// 每单限用次数。
+ ///
+ public int? PerOrderLimit { get; set; }
+
+ ///
+ /// 每人限购。
+ ///
+ public int? PerUserPurchaseLimit { get; set; }
+
+ ///
+ /// 是否允许转赠。
+ ///
+ public bool AllowTransfer { get; set; }
+
+ ///
+ /// 过期策略(invalidate/refund)。
+ ///
+ public string ExpireStrategy { get; set; } = "invalidate";
+
+ ///
+ /// 次卡描述。
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// 通知渠道(in_app/sms)。
+ ///
+ public List NotifyChannels { get; set; } = [];
+}
+
+///
+/// 次卡状态修改请求。
+///
+public sealed class ChangePunchCardStatusRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡 ID。
+ ///
+ public string PunchCardId { get; set; } = string.Empty;
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "disabled";
+}
+
+///
+/// 次卡删除请求。
+///
+public sealed class DeletePunchCardRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡 ID。
+ ///
+ public string PunchCardId { get; set; } = string.Empty;
+}
+
+///
+/// 次卡使用记录查询请求。
+///
+public sealed class PunchCardUsageRecordListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡模板 ID。
+ ///
+ public string? PunchCardId { get; set; }
+
+ ///
+ /// 状态筛选(normal/used_up/expired)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 关键字(会员/商品)。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 次卡使用记录导出请求。
+///
+public sealed class ExportPunchCardUsageRecordRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡模板 ID。
+ ///
+ public string? PunchCardId { get; set; }
+
+ ///
+ /// 状态筛选(normal/used_up/expired)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 关键字(会员/商品)。
+ ///
+ public string? Keyword { get; set; }
+}
+
+///
+/// 写入次卡使用记录请求。
+///
+public sealed class WritePunchCardUsageRecordRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡模板 ID。
+ ///
+ public string PunchCardId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡实例 ID(可空)。
+ ///
+ public string? PunchCardInstanceId { get; set; }
+
+ ///
+ /// 次卡实例编号(可空)。
+ ///
+ public string? PunchCardInstanceNo { get; set; }
+
+ ///
+ /// 会员名称。
+ ///
+ public string? MemberName { get; set; }
+
+ ///
+ /// 会员手机号(脱敏)。
+ ///
+ public string? MemberPhoneMasked { get; set; }
+
+ ///
+ /// 兑换商品。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 使用时间。
+ ///
+ public DateTime? UsedAt { get; set; }
+
+ ///
+ /// 本次使用次数。
+ ///
+ public int UsedTimes { get; set; } = 1;
+
+ ///
+ /// 超额补差金额。
+ ///
+ public decimal? ExtraPayAmount { get; set; }
+}
+
+///
+/// 次卡模板统计。
+///
+public sealed class PunchCardStatsResponse
+{
+ ///
+ /// 在售次卡数量。
+ ///
+ public int OnSaleCount { get; set; }
+
+ ///
+ /// 累计售出数量。
+ ///
+ public int TotalSoldCount { get; set; }
+
+ ///
+ /// 累计收入。
+ ///
+ public decimal TotalRevenueAmount { get; set; }
+
+ ///
+ /// 使用中数量。
+ ///
+ public int ActiveInUseCount { get; set; }
+}
+
+///
+/// 次卡列表项。
+///
+public sealed class PunchCardListItemResponse
+{
+ ///
+ /// 次卡 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 次卡名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 封面图。
+ ///
+ public string? CoverImageUrl { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal SalePrice { get; set; }
+
+ ///
+ /// 原价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 总次数。
+ ///
+ public int TotalTimes { get; set; }
+
+ ///
+ /// 有效期展示。
+ ///
+ public string ValiditySummary { get; set; } = string.Empty;
+
+ ///
+ /// 适用范围类型。
+ ///
+ public string ScopeType { get; set; } = "all";
+
+ ///
+ /// 使用模式。
+ ///
+ public string UsageMode { get; set; } = "free";
+
+ ///
+ /// 单次上限金额。
+ ///
+ public decimal? UsageCapAmount { get; set; }
+
+ ///
+ /// 每日限用。
+ ///
+ public int? DailyLimit { get; set; }
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+
+ ///
+ /// 是否弱化展示。
+ ///
+ public bool IsDimmed { get; set; }
+
+ ///
+ /// 已售数量。
+ ///
+ public int SoldCount { get; set; }
+
+ ///
+ /// 使用中数量。
+ ///
+ public int ActiveCount { get; set; }
+
+ ///
+ /// 累计收入。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 更新时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// 次卡列表结果。
+///
+public sealed class PunchCardListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 当前页。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 统计。
+ ///
+ public PunchCardStatsResponse Stats { get; set; } = new();
+}
+
+///
+/// 次卡范围。
+///
+public sealed class PunchCardScopeResponse
+{
+ ///
+ /// 范围类型(all/category/tag/product)。
+ ///
+ public string ScopeType { get; set; } = "all";
+
+ ///
+ /// 分类 ID。
+ ///
+ public List CategoryIds { get; set; } = [];
+
+ ///
+ /// 标签 ID。
+ ///
+ public List TagIds { get; set; } = [];
+
+ ///
+ /// 商品 ID。
+ ///
+ public List ProductIds { get; set; } = [];
+}
+
+///
+/// 次卡详情。
+///
+public sealed class PunchCardDetailResponse
+{
+ ///
+ /// 次卡 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 封面图。
+ ///
+ public string? CoverImageUrl { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal SalePrice { get; set; }
+
+ ///
+ /// 原价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 总次数。
+ ///
+ public int TotalTimes { get; set; }
+
+ ///
+ /// 有效期类型(days/range)。
+ ///
+ public string ValidityType { get; set; } = "days";
+
+ ///
+ /// 固定天数。
+ ///
+ public int? ValidityDays { get; set; }
+
+ ///
+ /// 固定开始日期(yyyy-MM-dd)。
+ ///
+ public string? ValidFrom { get; set; }
+
+ ///
+ /// 固定结束日期(yyyy-MM-dd)。
+ ///
+ public string? ValidTo { get; set; }
+
+ ///
+ /// 适用范围。
+ ///
+ public PunchCardScopeResponse Scope { get; set; } = new();
+
+ ///
+ /// 使用模式(free/cap)。
+ ///
+ public string UsageMode { get; set; } = "free";
+
+ ///
+ /// 单次上限金额。
+ ///
+ public decimal? UsageCapAmount { get; set; }
+
+ ///
+ /// 每日限用。
+ ///
+ public int? DailyLimit { get; set; }
+
+ ///
+ /// 每单限用。
+ ///
+ public int? PerOrderLimit { get; set; }
+
+ ///
+ /// 每人限购。
+ ///
+ public int? PerUserPurchaseLimit { get; set; }
+
+ ///
+ /// 是否允许转赠。
+ ///
+ public bool AllowTransfer { get; set; }
+
+ ///
+ /// 过期策略(invalidate/refund)。
+ ///
+ public string ExpireStrategy { get; set; } = "invalidate";
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// 通知渠道。
+ ///
+ public List NotifyChannels { get; set; } = [];
+
+ ///
+ /// 状态(enabled/disabled)。
+ ///
+ public string Status { get; set; } = "enabled";
+
+ ///
+ /// 已售数量。
+ ///
+ public int SoldCount { get; set; }
+
+ ///
+ /// 使用中数量。
+ ///
+ public int ActiveCount { get; set; }
+
+ ///
+ /// 累计收入。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 更新时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// 次卡下拉选项。
+///
+public sealed class PunchCardTemplateOptionResponse
+{
+ ///
+ /// 次卡 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+}
+
+///
+/// 使用记录统计。
+///
+public sealed class PunchCardUsageStatsResponse
+{
+ ///
+ /// 今日使用次数。
+ ///
+ public int TodayUsedCount { get; set; }
+
+ ///
+ /// 本月使用次数。
+ ///
+ public int MonthUsedCount { get; set; }
+
+ ///
+ /// 7 天内即将过期数量。
+ ///
+ public int ExpiringSoonCount { get; set; }
+}
+
+///
+/// 次卡使用记录项。
+///
+public sealed class PunchCardUsageRecordResponse
+{
+ ///
+ /// 使用记录 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 使用单号。
+ ///
+ public string RecordNo { get; set; } = string.Empty;
+
+ ///
+ /// 次卡模板 ID。
+ ///
+ public string PunchCardId { get; set; } = string.Empty;
+
+ ///
+ /// 次卡名称。
+ ///
+ public string PunchCardName { get; set; } = string.Empty;
+
+ ///
+ /// 次卡实例 ID。
+ ///
+ public string PunchCardInstanceId { get; set; } = string.Empty;
+
+ ///
+ /// 会员名称。
+ ///
+ public string MemberName { get; set; } = string.Empty;
+
+ ///
+ /// 会员手机号(脱敏)。
+ ///
+ public string MemberPhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 兑换商品。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 使用时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string UsedAt { get; set; } = string.Empty;
+
+ ///
+ /// 本次使用次数。
+ ///
+ public int UsedTimes { get; set; }
+
+ ///
+ /// 剩余次数。
+ ///
+ public int RemainingTimesAfterUse { get; set; }
+
+ ///
+ /// 总次数。
+ ///
+ public int TotalTimes { get; set; }
+
+ ///
+ /// 状态(normal/almost_used_up/used_up/expired)。
+ ///
+ public string DisplayStatus { get; set; } = "normal";
+
+ ///
+ /// 超额补差金额。
+ ///
+ public decimal? ExtraPayAmount { get; set; }
+}
+
+///
+/// 使用记录分页结果。
+///
+public sealed class PunchCardUsageRecordListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 统计。
+ ///
+ public PunchCardUsageStatsResponse Stats { get; set; } = new();
+
+ ///
+ /// 次卡筛选项。
+ ///
+ public List TemplateOptions { get; set; } = [];
+}
+
+///
+/// 使用记录导出回执。
+///
+public sealed class PunchCardUsageRecordExportResponse
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Base64 文件内容。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总条数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingPunchCardController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingPunchCardController.cs
new file mode 100644
index 0000000..6d241c9
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingPunchCardController.cs
@@ -0,0 +1,402 @@
+using System.Globalization;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
+using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
+using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+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/punch-card")]
+public sealed class MarketingPunchCardController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:marketing:punch-card:view";
+ private const string ManagePermission = "tenant:marketing:punch-card:manage";
+
+ ///
+ /// 获取次卡列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] PunchCardListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new GetPunchCardTemplateListQuery
+ {
+ StoreId = storeId,
+ Keyword = request.Keyword,
+ Status = request.Status,
+ Page = request.Page,
+ PageSize = request.PageSize
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new PunchCardListResultResponse
+ {
+ Items = result.Items.Select(MapListItem).ToList(),
+ Page = result.Page,
+ PageSize = result.PageSize,
+ TotalCount = result.TotalCount,
+ Stats = MapTemplateStats(result.Stats)
+ });
+ }
+
+ ///
+ /// 获取次卡详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] PunchCardDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
+ {
+ StoreId = storeId,
+ TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
+ }, cancellationToken);
+
+ if (result is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "次卡不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 保存次卡。
+ ///
+ [HttpPost("save")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Save(
+ [FromBody] SavePunchCardRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new SavePunchCardTemplateCommand
+ {
+ StoreId = storeId,
+ TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
+ Name = request.Name,
+ CoverImageUrl = request.CoverImageUrl,
+ SalePrice = request.SalePrice,
+ OriginalPrice = request.OriginalPrice,
+ TotalTimes = request.TotalTimes,
+ ValidityType = request.ValidityType,
+ ValidityDays = request.ValidityDays,
+ ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
+ ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
+ ScopeType = request.ScopeType,
+ ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
+ ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
+ ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
+ UsageMode = request.UsageMode,
+ UsageCapAmount = request.UsageCapAmount,
+ DailyLimit = request.DailyLimit,
+ PerOrderLimit = request.PerOrderLimit,
+ PerUserPurchaseLimit = request.PerUserPurchaseLimit,
+ AllowTransfer = request.AllowTransfer,
+ ExpireStrategy = request.ExpireStrategy,
+ Description = request.Description,
+ NotifyChannels = request.NotifyChannels
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 修改次卡状态。
+ ///
+ [HttpPost("status")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ChangeStatus(
+ [FromBody] ChangePunchCardStatusRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
+ {
+ StoreId = storeId,
+ TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
+ Status = request.Status
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 删除次卡。
+ ///
+ [HttpPost("delete")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse