diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index 5da102c..0941503 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit 5da102c97c2cd7acbd3f20d69f286b3d0eadf0fe
+Subproject commit 094150312469f156198500db5bce4581ea026cfa
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/NewCustomerContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/NewCustomerContracts.cs
new file mode 100644
index 0000000..aeb145a
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/NewCustomerContracts.cs
@@ -0,0 +1,489 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
+
+///
+/// 新客有礼详情请求。
+///
+public sealed class NewCustomerDetailRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 邀请记录页码。
+ ///
+ public int RecordPage { get; set; } = 1;
+
+ ///
+ /// 邀请记录每页条数。
+ ///
+ public int RecordPageSize { get; set; } = 10;
+}
+
+///
+/// 新客有礼配置保存请求。
+///
+public sealed class SaveNewCustomerSettingsRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 是否开启新客礼包。
+ ///
+ public bool GiftEnabled { get; set; }
+
+ ///
+ /// 礼包类型(coupon/direct)。
+ ///
+ public string GiftType { get; set; } = "coupon";
+
+ ///
+ /// 首单直减金额。
+ ///
+ public decimal? DirectReduceAmount { get; set; }
+
+ ///
+ /// 首单直减门槛金额。
+ ///
+ public decimal? DirectMinimumSpend { get; set; }
+
+ ///
+ /// 是否开启老带新分享。
+ ///
+ public bool InviteEnabled { get; set; }
+
+ ///
+ /// 分享渠道(wechat_friend/moments/sms)。
+ ///
+ public List ShareChannels { get; set; } = [];
+
+ ///
+ /// 新客礼包券列表。
+ ///
+ public List WelcomeCoupons { get; set; } = [];
+
+ ///
+ /// 邀请人奖励券列表。
+ ///
+ public List InviterCoupons { get; set; } = [];
+
+ ///
+ /// 被邀请人奖励券列表。
+ ///
+ public List InviteeCoupons { get; set; } = [];
+}
+
+///
+/// 新客邀请记录分页请求。
+///
+public sealed class NewCustomerInviteRecordListRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 写入新客邀请记录请求。
+///
+public sealed class WriteNewCustomerInviteRecordRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 邀请人展示名。
+ ///
+ public string InviterName { get; set; } = string.Empty;
+
+ ///
+ /// 被邀请人展示名。
+ ///
+ public string InviteeName { get; set; } = string.Empty;
+
+ ///
+ /// 邀请时间。
+ ///
+ public DateTime InviteTime { get; set; }
+
+ ///
+ /// 订单状态(pending_order/ordered)。
+ ///
+ public string OrderStatus { get; set; } = "pending_order";
+
+ ///
+ /// 奖励状态(pending/issued)。
+ ///
+ public string RewardStatus { get; set; } = "pending";
+
+ ///
+ /// 奖励发放时间。
+ ///
+ public DateTime? RewardIssuedAt { get; set; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; set; }
+}
+
+///
+/// 写入新客成长记录请求。
+///
+public sealed class WriteNewCustomerGrowthRecordRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 顾客业务唯一键。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 顾客展示名。
+ ///
+ public string? CustomerName { get; set; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; set; }
+
+ ///
+ /// 礼包领取时间。
+ ///
+ public DateTime? GiftClaimedAt { get; set; }
+
+ ///
+ /// 首单时间。
+ ///
+ public DateTime? FirstOrderAt { get; set; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; set; }
+}
+
+///
+/// 保存优惠券规则请求项。
+///
+public sealed class NewCustomerSaveCouponRuleRequest
+{
+ ///
+ /// 券类型(amount_off/discount/free_shipping)。
+ ///
+ public string CouponType { get; set; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal? Value { get; set; }
+
+ ///
+ /// 使用门槛金额。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 有效期天数。
+ ///
+ public int ValidDays { get; set; }
+}
+
+///
+/// 新客有礼详情响应。
+///
+public sealed class NewCustomerDetailResponse
+{
+ ///
+ /// 配置详情。
+ ///
+ public NewCustomerSettingsResponse Settings { get; set; } = new();
+
+ ///
+ /// 统计数据。
+ ///
+ public NewCustomerStatsResponse Stats { get; set; } = new();
+
+ ///
+ /// 邀请记录分页。
+ ///
+ public NewCustomerInviteRecordListResultResponse InviteRecords { get; set; } = new();
+}
+
+///
+/// 新客有礼配置响应。
+///
+public sealed class NewCustomerSettingsResponse
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 是否开启新客礼包。
+ ///
+ public bool GiftEnabled { get; set; }
+
+ ///
+ /// 礼包类型(coupon/direct)。
+ ///
+ public string GiftType { get; set; } = "coupon";
+
+ ///
+ /// 首单直减金额。
+ ///
+ public decimal? DirectReduceAmount { get; set; }
+
+ ///
+ /// 首单直减门槛金额。
+ ///
+ public decimal? DirectMinimumSpend { get; set; }
+
+ ///
+ /// 是否开启老带新分享。
+ ///
+ public bool InviteEnabled { get; set; }
+
+ ///
+ /// 分享渠道(wechat_friend/moments/sms)。
+ ///
+ public List ShareChannels { get; set; } = [];
+
+ ///
+ /// 新客礼包券列表。
+ ///
+ public List WelcomeCoupons { get; set; } = [];
+
+ ///
+ /// 邀请人奖励券列表。
+ ///
+ public List InviterCoupons { get; set; } = [];
+
+ ///
+ /// 被邀请人奖励券列表。
+ ///
+ public List InviteeCoupons { get; set; } = [];
+
+ ///
+ /// 更新时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// 新客有礼统计响应。
+///
+public sealed class NewCustomerStatsResponse
+{
+ ///
+ /// 本月新客数。
+ ///
+ public int MonthlyNewCustomers { get; set; }
+
+ ///
+ /// 较上月增长人数。
+ ///
+ public int MonthlyGrowthCount { get; set; }
+
+ ///
+ /// 较上月增长百分比。
+ ///
+ public decimal MonthlyGrowthRatePercent { get; set; }
+
+ ///
+ /// 本月礼包领取率(百分比)。
+ ///
+ public decimal GiftClaimRate { get; set; }
+
+ ///
+ /// 本月礼包已领取人数。
+ ///
+ public int GiftClaimedCount { get; set; }
+
+ ///
+ /// 本月首单转化率(百分比)。
+ ///
+ public decimal FirstOrderConversionRate { get; set; }
+
+ ///
+ /// 本月首单完成人数。
+ ///
+ public int FirstOrderedCount { get; set; }
+}
+
+///
+/// 邀请记录分页结果响应。
+///
+public sealed class NewCustomerInviteRecordListResultResponse
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 新客邀请记录响应。
+///
+public sealed class NewCustomerInviteRecordResponse
+{
+ ///
+ /// 记录 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 邀请人展示名。
+ ///
+ public string InviterName { get; set; } = string.Empty;
+
+ ///
+ /// 被邀请人展示名。
+ ///
+ public string InviteeName { get; set; } = string.Empty;
+
+ ///
+ /// 邀请时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string InviteTime { get; set; } = string.Empty;
+
+ ///
+ /// 订单状态(pending_order/ordered)。
+ ///
+ public string OrderStatus { get; set; } = "pending_order";
+
+ ///
+ /// 奖励状态(pending/issued)。
+ ///
+ public string RewardStatus { get; set; } = "pending";
+
+ ///
+ /// 奖励发放时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? RewardIssuedAt { get; set; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; set; }
+}
+
+///
+/// 新客成长记录响应。
+///
+public sealed class NewCustomerGrowthRecordResponse
+{
+ ///
+ /// 记录 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 顾客业务唯一键。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 顾客展示名。
+ ///
+ public string? CustomerName { get; set; }
+
+ ///
+ /// 注册时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string RegisteredAt { get; set; } = string.Empty;
+
+ ///
+ /// 礼包领取时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? GiftClaimedAt { get; set; }
+
+ ///
+ /// 首单时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? FirstOrderAt { get; set; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; set; }
+}
+
+///
+/// 新客券规则响应。
+///
+public sealed class NewCustomerCouponRuleResponse
+{
+ ///
+ /// 规则 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 场景(welcome/inviter/invitee)。
+ ///
+ public string Scene { get; set; } = "welcome";
+
+ ///
+ /// 券类型(amount_off/discount/free_shipping)。
+ ///
+ public string CouponType { get; set; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal? Value { get; set; }
+
+ ///
+ /// 使用门槛金额。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 有效期天数。
+ ///
+ public int ValidDays { get; set; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingNewCustomerController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingNewCustomerController.cs
new file mode 100644
index 0000000..486a431
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingNewCustomerController.cs
@@ -0,0 +1,297 @@
+using System.Globalization;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+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/new-customer")]
+public sealed class MarketingNewCustomerController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:marketing:new-customer:view";
+ private const string ManagePermission = "tenant:marketing:new-customer:manage";
+
+ ///
+ /// 获取新客有礼详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] NewCustomerDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验门店权限
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 查询应用层详情
+ var result = await mediator.Send(new GetNewCustomerDetailQuery
+ {
+ StoreId = storeId,
+ RecordPage = request.RecordPage,
+ RecordPageSize = request.RecordPageSize
+ }, cancellationToken);
+
+ // 3. 返回响应
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 保存新客有礼配置。
+ ///
+ [HttpPost("save")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Save(
+ [FromBody] SaveNewCustomerSettingsRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验门店权限
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 调用应用层保存
+ var result = await mediator.Send(new SaveNewCustomerSettingsCommand
+ {
+ StoreId = storeId,
+ GiftEnabled = request.GiftEnabled,
+ GiftType = request.GiftType,
+ DirectReduceAmount = request.DirectReduceAmount,
+ DirectMinimumSpend = request.DirectMinimumSpend,
+ InviteEnabled = request.InviteEnabled,
+ ShareChannels = request.ShareChannels,
+ WelcomeCoupons = request.WelcomeCoupons.Select(MapSaveCouponRule).ToList(),
+ InviterCoupons = request.InviterCoupons.Select(MapSaveCouponRule).ToList(),
+ InviteeCoupons = request.InviteeCoupons.Select(MapSaveCouponRule).ToList()
+ }, cancellationToken);
+
+ // 3. 返回响应
+ return ApiResponse.Ok(MapSettings(result));
+ }
+
+ ///
+ /// 获取新客邀请记录分页。
+ ///
+ [HttpGet("invite-record/list")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> InviteRecordList(
+ [FromQuery] NewCustomerInviteRecordListRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验门店权限
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 查询应用层分页
+ var result = await mediator.Send(new GetNewCustomerInviteRecordListQuery
+ {
+ StoreId = storeId,
+ Page = request.Page,
+ PageSize = request.PageSize
+ }, cancellationToken);
+
+ // 3. 返回响应
+ return ApiResponse.Ok(MapInviteRecordList(result));
+ }
+
+ ///
+ /// 写入新客邀请记录。
+ ///
+ [HttpPost("invite-record/write")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> WriteInviteRecord(
+ [FromBody] WriteNewCustomerInviteRecordRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验门店权限
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 调用应用层写入
+ var result = await mediator.Send(new WriteNewCustomerInviteRecordCommand
+ {
+ StoreId = storeId,
+ InviterName = request.InviterName,
+ InviteeName = request.InviteeName,
+ InviteTime = request.InviteTime,
+ OrderStatus = request.OrderStatus,
+ RewardStatus = request.RewardStatus,
+ RewardIssuedAt = request.RewardIssuedAt,
+ SourceChannel = request.SourceChannel
+ }, cancellationToken);
+
+ // 3. 返回响应
+ return ApiResponse.Ok(MapInviteRecord(result));
+ }
+
+ ///
+ /// 写入新客成长记录。
+ ///
+ [HttpPost("growth-record/write")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> WriteGrowthRecord(
+ [FromBody] WriteNewCustomerGrowthRecordRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验门店权限
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 调用应用层写入
+ var result = await mediator.Send(new WriteNewCustomerGrowthRecordCommand
+ {
+ StoreId = storeId,
+ CustomerKey = request.CustomerKey,
+ CustomerName = request.CustomerName,
+ RegisteredAt = request.RegisteredAt,
+ GiftClaimedAt = request.GiftClaimedAt,
+ FirstOrderAt = request.FirstOrderAt,
+ SourceChannel = request.SourceChannel
+ }, cancellationToken);
+
+ // 3. 返回响应
+ return ApiResponse.Ok(MapGrowthRecord(result));
+ }
+
+ private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
+ {
+ var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
+ await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
+ }
+
+ private static NewCustomerSaveCouponRuleInputDto MapSaveCouponRule(NewCustomerSaveCouponRuleRequest source)
+ {
+ return new NewCustomerSaveCouponRuleInputDto
+ {
+ CouponType = source.CouponType,
+ Value = source.Value,
+ MinimumSpend = source.MinimumSpend,
+ ValidDays = source.ValidDays
+ };
+ }
+
+ private static NewCustomerDetailResponse MapDetail(NewCustomerDetailDto source)
+ {
+ return new NewCustomerDetailResponse
+ {
+ Settings = MapSettings(source.Settings),
+ Stats = new NewCustomerStatsResponse
+ {
+ MonthlyNewCustomers = source.Stats.MonthlyNewCustomers,
+ MonthlyGrowthCount = source.Stats.MonthlyGrowthCount,
+ MonthlyGrowthRatePercent = source.Stats.MonthlyGrowthRatePercent,
+ GiftClaimRate = source.Stats.GiftClaimRate,
+ GiftClaimedCount = source.Stats.GiftClaimedCount,
+ FirstOrderConversionRate = source.Stats.FirstOrderConversionRate,
+ FirstOrderedCount = source.Stats.FirstOrderedCount
+ },
+ InviteRecords = MapInviteRecordList(source.InviteRecords)
+ };
+ }
+
+ private static NewCustomerSettingsResponse MapSettings(NewCustomerSettingsDto source)
+ {
+ return new NewCustomerSettingsResponse
+ {
+ StoreId = source.StoreId.ToString(),
+ GiftEnabled = source.GiftEnabled,
+ GiftType = source.GiftType,
+ DirectReduceAmount = source.DirectReduceAmount,
+ DirectMinimumSpend = source.DirectMinimumSpend,
+ InviteEnabled = source.InviteEnabled,
+ ShareChannels = source.ShareChannels.ToList(),
+ WelcomeCoupons = source.WelcomeCoupons.Select(MapCouponRule).ToList(),
+ InviterCoupons = source.InviterCoupons.Select(MapCouponRule).ToList(),
+ InviteeCoupons = source.InviteeCoupons.Select(MapCouponRule).ToList(),
+ UpdatedAt = ToDateTime(source.UpdatedAt)
+ };
+ }
+
+ private static NewCustomerCouponRuleResponse MapCouponRule(NewCustomerCouponRuleDto source)
+ {
+ return new NewCustomerCouponRuleResponse
+ {
+ Id = source.Id.ToString(),
+ Scene = source.Scene,
+ CouponType = source.CouponType,
+ Value = source.Value,
+ MinimumSpend = source.MinimumSpend,
+ ValidDays = source.ValidDays,
+ SortOrder = source.SortOrder
+ };
+ }
+
+ private static NewCustomerInviteRecordListResultResponse MapInviteRecordList(
+ NewCustomerInviteRecordListResultDto source)
+ {
+ return new NewCustomerInviteRecordListResultResponse
+ {
+ Items = source.Items.Select(MapInviteRecord).ToList(),
+ Page = source.Page,
+ PageSize = source.PageSize,
+ TotalCount = source.TotalCount
+ };
+ }
+
+ private static NewCustomerInviteRecordResponse MapInviteRecord(NewCustomerInviteRecordDto source)
+ {
+ return new NewCustomerInviteRecordResponse
+ {
+ Id = source.Id.ToString(),
+ InviterName = source.InviterName,
+ InviteeName = source.InviteeName,
+ InviteTime = ToDateTime(source.InviteTime),
+ OrderStatus = source.OrderStatus,
+ RewardStatus = source.RewardStatus,
+ RewardIssuedAt = source.RewardIssuedAt.HasValue
+ ? ToDateTime(source.RewardIssuedAt.Value)
+ : null,
+ SourceChannel = source.SourceChannel
+ };
+ }
+
+ private static NewCustomerGrowthRecordResponse MapGrowthRecord(NewCustomerGrowthRecordDto source)
+ {
+ return new NewCustomerGrowthRecordResponse
+ {
+ Id = source.Id.ToString(),
+ CustomerKey = source.CustomerKey,
+ CustomerName = source.CustomerName,
+ RegisteredAt = ToDateTime(source.RegisteredAt),
+ GiftClaimedAt = source.GiftClaimedAt.HasValue
+ ? ToDateTime(source.GiftClaimedAt.Value)
+ : null,
+ FirstOrderAt = source.FirstOrderAt.HasValue
+ ? ToDateTime(source.FirstOrderAt.Value)
+ : null,
+ SourceChannel = source.SourceChannel
+ };
+ }
+
+ private static string ToDateTime(DateTime value)
+ {
+ return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/SaveNewCustomerSettingsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/SaveNewCustomerSettingsCommand.cs
new file mode 100644
index 0000000..061c22c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/SaveNewCustomerSettingsCommand.cs
@@ -0,0 +1,60 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+
+///
+/// 保存新客有礼配置。
+///
+public sealed class SaveNewCustomerSettingsCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 是否开启新客礼包。
+ ///
+ public bool GiftEnabled { get; init; }
+
+ ///
+ /// 礼包类型(coupon/direct)。
+ ///
+ public string GiftType { get; init; } = "coupon";
+
+ ///
+ /// 首单直减金额。
+ ///
+ public decimal? DirectReduceAmount { get; init; }
+
+ ///
+ /// 首单直减门槛金额。
+ ///
+ public decimal? DirectMinimumSpend { get; init; }
+
+ ///
+ /// 是否开启邀请分享。
+ ///
+ public bool InviteEnabled { get; init; }
+
+ ///
+ /// 分享渠道。
+ ///
+ public IReadOnlyCollection ShareChannels { get; init; } = [];
+
+ ///
+ /// 新客礼包券。
+ ///
+ public IReadOnlyCollection WelcomeCoupons { get; init; } = [];
+
+ ///
+ /// 邀请人奖励券。
+ ///
+ public IReadOnlyCollection InviterCoupons { get; init; } = [];
+
+ ///
+ /// 被邀请人奖励券。
+ ///
+ public IReadOnlyCollection InviteeCoupons { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerGrowthRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerGrowthRecordCommand.cs
new file mode 100644
index 0000000..cc75df5
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerGrowthRecordCommand.cs
@@ -0,0 +1,45 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+
+///
+/// 写入新客成长记录。
+///
+public sealed class WriteNewCustomerGrowthRecordCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 顾客业务唯一键。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 顾客展示名。
+ ///
+ public string? CustomerName { get; init; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 礼包领取时间。
+ ///
+ public DateTime? GiftClaimedAt { get; init; }
+
+ ///
+ /// 首单时间。
+ ///
+ public DateTime? FirstOrderAt { get; init; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerInviteRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerInviteRecordCommand.cs
new file mode 100644
index 0000000..9757139
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Commands/WriteNewCustomerInviteRecordCommand.cs
@@ -0,0 +1,50 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+
+///
+/// 写入新客邀请记录。
+///
+public sealed class WriteNewCustomerInviteRecordCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 邀请人展示名。
+ ///
+ public string InviterName { get; init; } = string.Empty;
+
+ ///
+ /// 被邀请人展示名。
+ ///
+ public string InviteeName { get; init; } = string.Empty;
+
+ ///
+ /// 邀请时间。
+ ///
+ public DateTime InviteTime { get; init; }
+
+ ///
+ /// 订单状态(pending_order/ordered)。
+ ///
+ public string OrderStatus { get; init; } = "pending_order";
+
+ ///
+ /// 奖励状态(pending/issued)。
+ ///
+ public string RewardStatus { get; init; } = "pending";
+
+ ///
+ /// 奖励发放时间。
+ ///
+ public DateTime? RewardIssuedAt { get; init; }
+
+ ///
+ /// 来源渠道。
+ ///
+ public string? SourceChannel { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerCouponRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerCouponRuleDto.cs
new file mode 100644
index 0000000..e5a4c70
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerCouponRuleDto.cs
@@ -0,0 +1,42 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客有礼券规则 DTO。
+///
+public sealed class NewCustomerCouponRuleDto
+{
+ ///
+ /// 规则 ID。
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// 场景(welcome/inviter/invitee)。
+ ///
+ public string Scene { get; init; } = "welcome";
+
+ ///
+ /// 券类型(amount_off/discount/free_shipping)。
+ ///
+ public string CouponType { get; init; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal? Value { get; init; }
+
+ ///
+ /// 使用门槛。
+ ///
+ public decimal? MinimumSpend { get; init; }
+
+ ///
+ /// 有效期天数。
+ ///
+ public int ValidDays { get; init; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerDetailDto.cs
new file mode 100644
index 0000000..d9cba4d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerDetailDto.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客有礼详情 DTO。
+///
+public sealed class NewCustomerDetailDto
+{
+ ///
+ /// 配置详情。
+ ///
+ public NewCustomerSettingsDto Settings { get; init; } = new();
+
+ ///
+ /// 统计数据。
+ ///
+ public NewCustomerStatsDto Stats { get; init; } = new();
+
+ ///
+ /// 邀请记录分页结果。
+ ///
+ public NewCustomerInviteRecordListResultDto InviteRecords { get; init; } = new();
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerGrowthRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerGrowthRecordDto.cs
new file mode 100644
index 0000000..7549fd7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerGrowthRecordDto.cs
@@ -0,0 +1,42 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客成长记录 DTO。
+///
+public sealed class NewCustomerGrowthRecordDto
+{
+ ///
+ /// 记录 ID。
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// 顾客业务键。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 顾客展示名。
+ ///
+ public string? CustomerName { get; init; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 礼包领取时间。
+ ///
+ public DateTime? GiftClaimedAt { get; init; }
+
+ ///
+ /// 首单时间。
+ ///
+ public DateTime? FirstOrderAt { get; init; }
+
+ ///
+ /// 渠道来源。
+ ///
+ public string? SourceChannel { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordDto.cs
new file mode 100644
index 0000000..ae7cf91
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordDto.cs
@@ -0,0 +1,47 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客邀请记录 DTO。
+///
+public sealed class NewCustomerInviteRecordDto
+{
+ ///
+ /// 记录 ID。
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// 邀请人展示名。
+ ///
+ public string InviterName { get; init; } = string.Empty;
+
+ ///
+ /// 被邀请人展示名。
+ ///
+ public string InviteeName { get; init; } = string.Empty;
+
+ ///
+ /// 邀请时间。
+ ///
+ public DateTime InviteTime { get; init; }
+
+ ///
+ /// 状态(pending_order/ordered)。
+ ///
+ public string OrderStatus { get; init; } = "pending_order";
+
+ ///
+ /// 奖励发放状态(pending/issued)。
+ ///
+ public string RewardStatus { get; init; } = "pending";
+
+ ///
+ /// 奖励发放时间。
+ ///
+ public DateTime? RewardIssuedAt { get; init; }
+
+ ///
+ /// 渠道来源。
+ ///
+ public string? SourceChannel { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordListResultDto.cs
new file mode 100644
index 0000000..3d830e4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerInviteRecordListResultDto.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客邀请记录分页结果 DTO。
+///
+public sealed class NewCustomerInviteRecordListResultDto
+{
+ ///
+ /// 列表项。
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSaveCouponRuleInputDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSaveCouponRuleInputDto.cs
new file mode 100644
index 0000000..d870ad0
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSaveCouponRuleInputDto.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 保存新客有礼券规则输入 DTO。
+///
+public sealed class NewCustomerSaveCouponRuleInputDto
+{
+ ///
+ /// 券类型(amount_off/discount/free_shipping)。
+ ///
+ public string CouponType { get; init; } = "amount_off";
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal? Value { get; init; }
+
+ ///
+ /// 使用门槛金额。
+ ///
+ public decimal? MinimumSpend { get; init; }
+
+ ///
+ /// 有效期天数。
+ ///
+ public int ValidDays { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSettingsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSettingsDto.cs
new file mode 100644
index 0000000..22bc8bf
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerSettingsDto.cs
@@ -0,0 +1,62 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客有礼配置 DTO。
+///
+public sealed class NewCustomerSettingsDto
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 是否开启新客礼包。
+ ///
+ public bool GiftEnabled { get; init; }
+
+ ///
+ /// 礼包类型(coupon/direct)。
+ ///
+ public string GiftType { get; init; } = "coupon";
+
+ ///
+ /// 首单直减金额。
+ ///
+ public decimal? DirectReduceAmount { get; init; }
+
+ ///
+ /// 首单直减门槛金额。
+ ///
+ public decimal? DirectMinimumSpend { get; init; }
+
+ ///
+ /// 是否开启老带新分享。
+ ///
+ public bool InviteEnabled { get; init; }
+
+ ///
+ /// 分享渠道。
+ ///
+ public IReadOnlyList ShareChannels { get; init; } = [];
+
+ ///
+ /// 新客礼包券列表。
+ ///
+ public IReadOnlyList WelcomeCoupons { get; init; } = [];
+
+ ///
+ /// 邀请人奖励券列表。
+ ///
+ public IReadOnlyList InviterCoupons { get; init; } = [];
+
+ ///
+ /// 被邀请人奖励券列表。
+ ///
+ public IReadOnlyList InviteeCoupons { get; init; } = [];
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime UpdatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerStatsDto.cs
new file mode 100644
index 0000000..415e95b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Dto/NewCustomerStatsDto.cs
@@ -0,0 +1,42 @@
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+///
+/// 新客有礼统计 DTO。
+///
+public sealed class NewCustomerStatsDto
+{
+ ///
+ /// 本月新客数。
+ ///
+ public int MonthlyNewCustomers { get; init; }
+
+ ///
+ /// 较上月增长人数。
+ ///
+ public int MonthlyGrowthCount { get; init; }
+
+ ///
+ /// 较上月增长百分比。
+ ///
+ public decimal MonthlyGrowthRatePercent { get; init; }
+
+ ///
+ /// 本月礼包领取率。
+ ///
+ public decimal GiftClaimRate { get; init; }
+
+ ///
+ /// 本月礼包已领取人数。
+ ///
+ public int GiftClaimedCount { get; init; }
+
+ ///
+ /// 本月首单转化率。
+ ///
+ public decimal FirstOrderConversionRate { get; init; }
+
+ ///
+ /// 本月首单完成人数。
+ ///
+ public int FirstOrderedCount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerDetailQueryHandler.cs
new file mode 100644
index 0000000..9fb9174
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerDetailQueryHandler.cs
@@ -0,0 +1,152 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
+using TakeoutSaaS.Domain.Coupons.Entities;
+using TakeoutSaaS.Domain.Coupons.Enums;
+using TakeoutSaaS.Domain.Coupons.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
+
+///
+/// 查询新客有礼详情处理器。
+///
+public sealed class GetNewCustomerDetailQueryHandler(
+ INewCustomerGiftRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetNewCustomerDetailQuery request, CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedPage = Math.Max(1, request.RecordPage);
+ var normalizedPageSize = Math.Clamp(request.RecordPageSize, 1, 200);
+
+ var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
+ var rules = await repository.GetCouponRulesByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
+ var settingsDto = BuildSettingsDto(request.StoreId, setting, rules);
+
+ var nowUtc = DateTime.UtcNow;
+ var currentMonthStart = NewCustomerMapping.StartOfMonthUtc(nowUtc);
+ var nextMonthStart = currentMonthStart.AddMonths(1);
+ var previousMonthStart = currentMonthStart.AddMonths(-1);
+
+ var currentMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
+ tenantId,
+ request.StoreId,
+ currentMonthStart,
+ nextMonthStart,
+ cancellationToken);
+
+ var previousMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
+ tenantId,
+ request.StoreId,
+ previousMonthStart,
+ currentMonthStart,
+ cancellationToken);
+
+ var currentMonthGiftClaimedCount = await repository.CountGiftClaimedCustomersAsync(
+ tenantId,
+ request.StoreId,
+ currentMonthStart,
+ nextMonthStart,
+ cancellationToken);
+
+ var currentMonthFirstOrderedCount = await repository.CountFirstOrderedCustomersAsync(
+ tenantId,
+ request.StoreId,
+ currentMonthStart,
+ nextMonthStart,
+ cancellationToken);
+
+ var stats = new NewCustomerStatsDto
+ {
+ MonthlyNewCustomers = currentMonthNewCustomerCount,
+ MonthlyGrowthCount = currentMonthNewCustomerCount - previousMonthNewCustomerCount,
+ MonthlyGrowthRatePercent = NewCustomerMapping.ToGrowthRatePercent(
+ currentMonthNewCustomerCount,
+ previousMonthNewCustomerCount),
+ GiftClaimRate = NewCustomerMapping.ToRatePercent(
+ currentMonthGiftClaimedCount,
+ currentMonthNewCustomerCount),
+ GiftClaimedCount = currentMonthGiftClaimedCount,
+ FirstOrderConversionRate = NewCustomerMapping.ToRatePercent(
+ currentMonthFirstOrderedCount,
+ currentMonthNewCustomerCount),
+ FirstOrderedCount = currentMonthFirstOrderedCount
+ };
+
+ var (records, totalCount) = await repository.GetInviteRecordsAsync(
+ tenantId,
+ request.StoreId,
+ normalizedPage,
+ normalizedPageSize,
+ cancellationToken);
+
+ return new NewCustomerDetailDto
+ {
+ Settings = settingsDto,
+ Stats = stats,
+ InviteRecords = new NewCustomerInviteRecordListResultDto
+ {
+ Items = records.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
+ Page = normalizedPage,
+ PageSize = normalizedPageSize,
+ TotalCount = totalCount
+ }
+ };
+ }
+
+ private static NewCustomerSettingsDto BuildSettingsDto(
+ long storeId,
+ NewCustomerGiftSetting? setting,
+ IReadOnlyList rules)
+ {
+ var welcomeCoupons = rules
+ .Where(item => item.Scene == NewCustomerCouponScene.Welcome)
+ .Select(NewCustomerMapping.ToCouponRuleDto)
+ .ToList();
+
+ var inviterCoupons = rules
+ .Where(item => item.Scene == NewCustomerCouponScene.InviterReward)
+ .Select(NewCustomerMapping.ToCouponRuleDto)
+ .ToList();
+
+ var inviteeCoupons = rules
+ .Where(item => item.Scene == NewCustomerCouponScene.InviteeReward)
+ .Select(NewCustomerMapping.ToCouponRuleDto)
+ .ToList();
+
+ if (setting is null)
+ {
+ return new NewCustomerSettingsDto
+ {
+ StoreId = storeId,
+ GiftEnabled = true,
+ GiftType = "coupon",
+ InviteEnabled = true,
+ ShareChannels = ["wechat_friend", "moments"],
+ WelcomeCoupons = welcomeCoupons,
+ InviterCoupons = inviterCoupons,
+ InviteeCoupons = inviteeCoupons,
+ UpdatedAt = DateTime.UtcNow
+ };
+ }
+
+ return new NewCustomerSettingsDto
+ {
+ StoreId = storeId,
+ GiftEnabled = setting.GiftEnabled,
+ GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
+ DirectReduceAmount = setting.DirectReduceAmount,
+ DirectMinimumSpend = setting.DirectMinimumSpend,
+ InviteEnabled = setting.InviteEnabled,
+ ShareChannels = NewCustomerMapping.DeserializeShareChannels(setting.ShareChannelsJson),
+ WelcomeCoupons = welcomeCoupons,
+ InviterCoupons = inviterCoupons,
+ InviteeCoupons = inviteeCoupons,
+ UpdatedAt = setting.UpdatedAt ?? setting.CreatedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerInviteRecordListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerInviteRecordListQueryHandler.cs
new file mode 100644
index 0000000..432b8b8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/GetNewCustomerInviteRecordListQueryHandler.cs
@@ -0,0 +1,41 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
+using TakeoutSaaS.Domain.Coupons.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
+
+///
+/// 查询新客邀请记录分页处理器。
+///
+public sealed class GetNewCustomerInviteRecordListQueryHandler(
+ INewCustomerGiftRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetNewCustomerInviteRecordListQuery request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedPage = Math.Max(1, request.Page);
+ var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
+
+ var (items, totalCount) = await repository.GetInviteRecordsAsync(
+ tenantId,
+ request.StoreId,
+ normalizedPage,
+ normalizedPageSize,
+ cancellationToken);
+
+ return new NewCustomerInviteRecordListResultDto
+ {
+ Items = items.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
+ Page = normalizedPage,
+ PageSize = normalizedPageSize,
+ TotalCount = totalCount
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/SaveNewCustomerSettingsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/SaveNewCustomerSettingsCommandHandler.cs
new file mode 100644
index 0000000..715d976
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/SaveNewCustomerSettingsCommandHandler.cs
@@ -0,0 +1,129 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.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.NewCustomer.Handlers;
+
+///
+/// 保存新客有礼配置处理器。
+///
+public sealed class SaveNewCustomerSettingsCommandHandler(
+ INewCustomerGiftRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ SaveNewCustomerSettingsCommand request,
+ CancellationToken cancellationToken)
+ {
+ if (request.StoreId <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
+ }
+
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var giftType = NewCustomerMapping.ParseGiftType(request.GiftType);
+ var shareChannels = NewCustomerMapping.NormalizeShareChannels(request.ShareChannels);
+
+ var welcomeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
+ request.StoreId,
+ NewCustomerCouponScene.Welcome,
+ request.WelcomeCoupons);
+
+ var inviterRules = NewCustomerMapping.NormalizeCouponRulesForSave(
+ request.StoreId,
+ NewCustomerCouponScene.InviterReward,
+ request.InviterCoupons);
+
+ var inviteeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
+ request.StoreId,
+ NewCustomerCouponScene.InviteeReward,
+ request.InviteeCoupons);
+
+ if (giftType == NewCustomerGiftType.Coupon && welcomeRules.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "优惠券包至少需要一张券");
+ }
+
+ if (giftType == NewCustomerGiftType.Direct)
+ {
+ if (!request.DirectReduceAmount.HasValue || request.DirectReduceAmount.Value <= 0m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "directReduceAmount 必须大于 0");
+ }
+
+ if (!request.DirectMinimumSpend.HasValue || request.DirectMinimumSpend.Value < 0m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "directMinimumSpend 不能小于 0");
+ }
+ }
+
+ if (request.InviteEnabled && (inviterRules.Count == 0 || inviteeRules.Count == 0))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "开启邀请后必须配置邀请人和被邀请人奖励券");
+ }
+
+ var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
+ var isNewSetting = setting is null;
+ if (setting is null)
+ {
+ setting = new NewCustomerGiftSetting
+ {
+ StoreId = request.StoreId
+ };
+
+ await repository.AddSettingAsync(setting, cancellationToken);
+ }
+
+ setting.GiftEnabled = request.GiftEnabled;
+ setting.GiftType = giftType;
+ setting.DirectReduceAmount = giftType == NewCustomerGiftType.Direct
+ ? decimal.Round(request.DirectReduceAmount!.Value, 2, MidpointRounding.AwayFromZero)
+ : null;
+ setting.DirectMinimumSpend = giftType == NewCustomerGiftType.Direct
+ ? decimal.Round(request.DirectMinimumSpend!.Value, 2, MidpointRounding.AwayFromZero)
+ : null;
+ setting.InviteEnabled = request.InviteEnabled;
+ setting.ShareChannelsJson = NewCustomerMapping.SerializeShareChannels(shareChannels);
+
+ if (!isNewSetting)
+ {
+ await repository.UpdateSettingAsync(setting, cancellationToken);
+ }
+
+ var allRules = new List(welcomeRules.Count + inviterRules.Count + inviteeRules.Count);
+ allRules.AddRange(welcomeRules);
+ allRules.AddRange(inviterRules);
+ allRules.AddRange(inviteeRules);
+
+ await repository.ReplaceCouponRulesAsync(
+ tenantId,
+ request.StoreId,
+ allRules,
+ cancellationToken);
+
+ await repository.SaveChangesAsync(cancellationToken);
+
+ return new NewCustomerSettingsDto
+ {
+ StoreId = request.StoreId,
+ GiftEnabled = setting.GiftEnabled,
+ GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
+ DirectReduceAmount = setting.DirectReduceAmount,
+ DirectMinimumSpend = setting.DirectMinimumSpend,
+ InviteEnabled = setting.InviteEnabled,
+ ShareChannels = shareChannels,
+ WelcomeCoupons = welcomeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
+ InviterCoupons = inviterRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
+ InviteeCoupons = inviteeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
+ UpdatedAt = DateTime.UtcNow
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerGrowthRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerGrowthRecordCommandHandler.cs
new file mode 100644
index 0000000..810fa44
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerGrowthRecordCommandHandler.cs
@@ -0,0 +1,101 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+using TakeoutSaaS.Domain.Coupons.Entities;
+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.NewCustomer.Handlers;
+
+///
+/// 写入新客成长记录处理器。
+///
+public sealed class WriteNewCustomerGrowthRecordCommandHandler(
+ INewCustomerGiftRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ WriteNewCustomerGrowthRecordCommand request,
+ CancellationToken cancellationToken)
+ {
+ if (request.StoreId <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
+ }
+
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var customerKey = NewCustomerMapping.NormalizeCustomerKey(request.CustomerKey);
+ var customerName = NewCustomerMapping.NormalizeOptionalText(request.CustomerName, "customerName", 64);
+ var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
+ var registeredAt = NewCustomerMapping.NormalizeUtc(request.RegisteredAt);
+ DateTime? giftClaimedAt = request.GiftClaimedAt.HasValue
+ ? NewCustomerMapping.NormalizeUtc(request.GiftClaimedAt.Value)
+ : null;
+ DateTime? firstOrderAt = request.FirstOrderAt.HasValue
+ ? NewCustomerMapping.NormalizeUtc(request.FirstOrderAt.Value)
+ : null;
+
+ var entity = await repository.FindGrowthRecordByCustomerKeyAsync(
+ tenantId: tenantId,
+ storeId: request.StoreId,
+ customerKey: customerKey,
+ cancellationToken: cancellationToken);
+
+ if (entity is null)
+ {
+ entity = new NewCustomerGrowthRecord
+ {
+ StoreId = request.StoreId,
+ CustomerKey = customerKey,
+ CustomerName = customerName,
+ RegisteredAt = registeredAt,
+ GiftClaimedAt = giftClaimedAt,
+ FirstOrderAt = firstOrderAt,
+ SourceChannel = sourceChannel
+ };
+
+ await repository.AddGrowthRecordAsync(entity, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+ return NewCustomerMapping.ToGrowthRecordDto(entity);
+ }
+
+ if (!string.IsNullOrWhiteSpace(customerName))
+ {
+ entity.CustomerName = customerName;
+ }
+
+ if (!string.IsNullOrWhiteSpace(sourceChannel))
+ {
+ entity.SourceChannel = sourceChannel;
+ }
+
+ entity.RegisteredAt = registeredAt < entity.RegisteredAt
+ ? registeredAt
+ : entity.RegisteredAt;
+ entity.GiftClaimedAt = MergeNullableDate(entity.GiftClaimedAt, giftClaimedAt);
+ entity.FirstOrderAt = MergeNullableDate(entity.FirstOrderAt, firstOrderAt);
+
+ await repository.UpdateGrowthRecordAsync(entity, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+ return NewCustomerMapping.ToGrowthRecordDto(entity);
+ }
+
+ private static DateTime? MergeNullableDate(DateTime? existing, DateTime? incoming)
+ {
+ if (!existing.HasValue)
+ {
+ return incoming;
+ }
+
+ if (!incoming.HasValue)
+ {
+ return existing;
+ }
+
+ return incoming.Value < existing.Value ? incoming : existing;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerInviteRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerInviteRecordCommandHandler.cs
new file mode 100644
index 0000000..2d2ebbd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Handlers/WriteNewCustomerInviteRecordCommandHandler.cs
@@ -0,0 +1,59 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.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;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
+
+///
+/// 写入新客邀请记录处理器。
+///
+public sealed class WriteNewCustomerInviteRecordCommandHandler(
+ INewCustomerGiftRepository repository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ WriteNewCustomerInviteRecordCommand request,
+ CancellationToken cancellationToken)
+ {
+ if (request.StoreId <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
+ }
+
+ var inviterName = NewCustomerMapping.NormalizeDisplayName(request.InviterName, "inviterName");
+ var inviteeName = NewCustomerMapping.NormalizeDisplayName(request.InviteeName, "inviteeName");
+ var orderStatus = NewCustomerMapping.ParseInviteOrderStatus(request.OrderStatus);
+ var rewardStatus = NewCustomerMapping.ParseInviteRewardStatus(request.RewardStatus);
+ var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
+
+ var inviteTime = NewCustomerMapping.NormalizeUtc(request.InviteTime);
+ DateTime? rewardIssuedAt = rewardStatus == NewCustomerInviteRewardStatus.Issued
+ ? request.RewardIssuedAt.HasValue
+ ? NewCustomerMapping.NormalizeUtc(request.RewardIssuedAt.Value)
+ : DateTime.UtcNow
+ : null;
+
+ var entity = new NewCustomerInviteRecord
+ {
+ StoreId = request.StoreId,
+ InviterName = inviterName,
+ InviteeName = inviteeName,
+ InviteTime = inviteTime,
+ OrderStatus = orderStatus,
+ RewardStatus = rewardStatus,
+ RewardIssuedAt = rewardIssuedAt,
+ SourceChannel = sourceChannel
+ };
+
+ await repository.AddInviteRecordAsync(entity, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+
+ return NewCustomerMapping.ToInviteRecordDto(entity);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/NewCustomerMapping.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/NewCustomerMapping.cs
new file mode 100644
index 0000000..4e1e79d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/NewCustomerMapping.cs
@@ -0,0 +1,394 @@
+using System.Globalization;
+using System.Text.Json;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+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.NewCustomer;
+
+///
+/// 新客有礼映射与规则校验。
+///
+internal static class NewCustomerMapping
+{
+ private static readonly HashSet AllowedShareChannels =
+ [
+ "wechat_friend",
+ "moments",
+ "sms"
+ ];
+
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ public static NewCustomerGiftType ParseGiftType(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "coupon" => NewCustomerGiftType.Coupon,
+ "direct" => NewCustomerGiftType.Direct,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "giftType 参数不合法")
+ };
+ }
+
+ public static string ToGiftTypeText(NewCustomerGiftType value)
+ {
+ return value switch
+ {
+ NewCustomerGiftType.Coupon => "coupon",
+ NewCustomerGiftType.Direct => "direct",
+ _ => "coupon"
+ };
+ }
+
+ public static NewCustomerCouponScene ParseCouponScene(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "welcome" => NewCustomerCouponScene.Welcome,
+ "inviter" => NewCustomerCouponScene.InviterReward,
+ "invitee" => NewCustomerCouponScene.InviteeReward,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "scene 参数不合法")
+ };
+ }
+
+ public static string ToCouponSceneText(NewCustomerCouponScene value)
+ {
+ return value switch
+ {
+ NewCustomerCouponScene.Welcome => "welcome",
+ NewCustomerCouponScene.InviterReward => "inviter",
+ NewCustomerCouponScene.InviteeReward => "invitee",
+ _ => "welcome"
+ };
+ }
+
+ public static NewCustomerCouponType ParseCouponType(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "amount_off" => NewCustomerCouponType.AmountOff,
+ "discount" => NewCustomerCouponType.Discount,
+ "free_shipping" => NewCustomerCouponType.FreeShipping,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
+ };
+ }
+
+ public static string ToCouponTypeText(NewCustomerCouponType value)
+ {
+ return value switch
+ {
+ NewCustomerCouponType.AmountOff => "amount_off",
+ NewCustomerCouponType.Discount => "discount",
+ NewCustomerCouponType.FreeShipping => "free_shipping",
+ _ => "amount_off"
+ };
+ }
+
+ public static NewCustomerInviteOrderStatus ParseInviteOrderStatus(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "pending_order" => NewCustomerInviteOrderStatus.PendingOrder,
+ "ordered" => NewCustomerInviteOrderStatus.Ordered,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "orderStatus 参数不合法")
+ };
+ }
+
+ public static string ToInviteOrderStatusText(NewCustomerInviteOrderStatus value)
+ {
+ return value switch
+ {
+ NewCustomerInviteOrderStatus.PendingOrder => "pending_order",
+ NewCustomerInviteOrderStatus.Ordered => "ordered",
+ _ => "pending_order"
+ };
+ }
+
+ public static NewCustomerInviteRewardStatus ParseInviteRewardStatus(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "pending" => NewCustomerInviteRewardStatus.Pending,
+ "issued" => NewCustomerInviteRewardStatus.Issued,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "rewardStatus 参数不合法")
+ };
+ }
+
+ public static string ToInviteRewardStatusText(NewCustomerInviteRewardStatus value)
+ {
+ return value switch
+ {
+ NewCustomerInviteRewardStatus.Pending => "pending",
+ NewCustomerInviteRewardStatus.Issued => "issued",
+ _ => "pending"
+ };
+ }
+
+ public static DateTime NormalizeUtc(DateTime value)
+ {
+ return value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+ }
+
+ public static DateTime StartOfMonthUtc(DateTime nowUtc)
+ {
+ var utc = NormalizeUtc(nowUtc);
+ return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ public static decimal ToRatePercent(int numerator, int denominator)
+ {
+ if (denominator <= 0 || numerator <= 0)
+ {
+ return 0m;
+ }
+
+ return decimal.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero);
+ }
+
+ public static decimal ToGrowthRatePercent(int currentValue, int previousValue)
+ {
+ if (previousValue <= 0)
+ {
+ return currentValue > 0 ? 100m : 0m;
+ }
+
+ return decimal.Round(
+ (currentValue - previousValue) * 100m / previousValue,
+ 1,
+ MidpointRounding.AwayFromZero);
+ }
+
+ public static string SerializeShareChannels(IReadOnlyCollection channels)
+ {
+ return JsonSerializer.Serialize(channels, JsonOptions);
+ }
+
+ public static IReadOnlyList DeserializeShareChannels(string? payload)
+ {
+ if (string.IsNullOrWhiteSpace(payload))
+ {
+ return [];
+ }
+
+ var values = JsonSerializer.Deserialize>(payload, JsonOptions) ?? [];
+ return NormalizeShareChannels(values);
+ }
+
+ public static IReadOnlyList NormalizeShareChannels(IEnumerable? values)
+ {
+ var normalized = (values ?? [])
+ .Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
+ .Where(item => !string.IsNullOrWhiteSpace(item))
+ .Distinct()
+ .ToList();
+
+ if (normalized.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 不能为空");
+ }
+
+ if (normalized.Any(item => !AllowedShareChannels.Contains(item)))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 存在非法值");
+ }
+
+ return normalized;
+ }
+
+ public static IReadOnlyList NormalizeCouponRulesForSave(
+ long storeId,
+ NewCustomerCouponScene scene,
+ IReadOnlyCollection? values)
+ {
+ var rules = (values ?? [])
+ .Select((item, index) => NormalizeCouponRuleForSave(storeId, scene, item, index + 1))
+ .ToList();
+
+ return rules;
+ }
+
+ public static NewCustomerCouponRuleDto ToCouponRuleDto(NewCustomerCouponRule source)
+ {
+ return new NewCustomerCouponRuleDto
+ {
+ Id = source.Id,
+ Scene = ToCouponSceneText(source.Scene),
+ CouponType = ToCouponTypeText(source.CouponType),
+ Value = source.Value,
+ MinimumSpend = source.MinimumSpend,
+ ValidDays = source.ValidDays,
+ SortOrder = source.SortOrder
+ };
+ }
+
+ public static NewCustomerInviteRecordDto ToInviteRecordDto(NewCustomerInviteRecord source)
+ {
+ return new NewCustomerInviteRecordDto
+ {
+ Id = source.Id,
+ InviterName = source.InviterName,
+ InviteeName = source.InviteeName,
+ InviteTime = source.InviteTime,
+ OrderStatus = ToInviteOrderStatusText(source.OrderStatus),
+ RewardStatus = ToInviteRewardStatusText(source.RewardStatus),
+ RewardIssuedAt = source.RewardIssuedAt,
+ SourceChannel = source.SourceChannel
+ };
+ }
+
+ public static NewCustomerGrowthRecordDto ToGrowthRecordDto(NewCustomerGrowthRecord source)
+ {
+ return new NewCustomerGrowthRecordDto
+ {
+ Id = source.Id,
+ CustomerKey = source.CustomerKey,
+ CustomerName = source.CustomerName,
+ RegisteredAt = source.RegisteredAt,
+ GiftClaimedAt = source.GiftClaimedAt,
+ FirstOrderAt = source.FirstOrderAt,
+ SourceChannel = source.SourceChannel
+ };
+ }
+
+ public static string NormalizeDisplayName(string? value, string fieldName)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
+ }
+
+ if (normalized.Length > 64)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64");
+ }
+
+ return normalized;
+ }
+
+ public static string NormalizeCustomerKey(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "customerKey 不能为空");
+ }
+
+ if (normalized.Length > 64)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "customerKey 长度不能超过 64");
+ }
+
+ return normalized;
+ }
+
+ public static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ if (normalized.Length == 0)
+ {
+ return null;
+ }
+
+ if (normalized.Length > maxLength)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
+ }
+
+ return normalized;
+ }
+
+ private static NewCustomerCouponRule NormalizeCouponRuleForSave(
+ long storeId,
+ NewCustomerCouponScene scene,
+ NewCustomerSaveCouponRuleInputDto value,
+ int sortOrder)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ var couponType = ParseCouponType(value.CouponType);
+ var minimumSpend = NormalizeNonNegativeMoney(value.MinimumSpend, "minimumSpend");
+ decimal? normalizedValue = couponType switch
+ {
+ NewCustomerCouponType.AmountOff => NormalizePositiveMoney(value.Value, "value"),
+ NewCustomerCouponType.Discount => NormalizeDiscount(value.Value),
+ NewCustomerCouponType.FreeShipping => null,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
+ };
+
+ if (couponType == NewCustomerCouponType.AmountOff &&
+ minimumSpend.HasValue &&
+ normalizedValue.HasValue &&
+ normalizedValue.Value >= minimumSpend.Value &&
+ minimumSpend.Value > 0m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "满减券 value 必须小于 minimumSpend");
+ }
+
+ if (value.ValidDays is < 1 or > 365)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "validDays 必须在 1-365 之间");
+ }
+
+ return new NewCustomerCouponRule
+ {
+ StoreId = storeId,
+ Scene = scene,
+ CouponType = couponType,
+ Value = normalizedValue,
+ MinimumSpend = minimumSpend,
+ ValidDays = value.ValidDays,
+ SortOrder = sortOrder
+ };
+ }
+
+ private static decimal? NormalizeNonNegativeMoney(decimal? value, string fieldName)
+ {
+ if (!value.HasValue)
+ {
+ return null;
+ }
+
+ if (value.Value < 0m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能小于 0");
+ }
+
+ return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal NormalizePositiveMoney(decimal? value, string fieldName)
+ {
+ if (!value.HasValue || value.Value <= 0m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0");
+ }
+
+ return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal NormalizeDiscount(decimal? value)
+ {
+ if (!value.HasValue || value.Value <= 0m || value.Value >= 10m)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "折扣券 value 必须在 0-10 之间");
+ }
+
+ return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerDetailQuery.cs
new file mode 100644
index 0000000..4e0c849
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerDetailQuery.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
+
+///
+/// 查询新客有礼详情。
+///
+public sealed class GetNewCustomerDetailQuery : IRequest
+{
+ ///
+ /// 操作门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 邀请记录页码。
+ ///
+ public int RecordPage { get; init; } = 1;
+
+ ///
+ /// 邀请记录每页条数。
+ ///
+ public int RecordPageSize { get; init; } = 10;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerInviteRecordListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerInviteRecordListQuery.cs
new file mode 100644
index 0000000..8fa45b2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/NewCustomer/Queries/GetNewCustomerInviteRecordListQuery.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
+
+///
+/// 查询新客邀请记录分页。
+///
+public sealed class GetNewCustomerInviteRecordListQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 10;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerCouponRule.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerCouponRule.cs
new file mode 100644
index 0000000..0ee81d4
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerCouponRule.cs
@@ -0,0 +1,45 @@
+using TakeoutSaaS.Domain.Coupons.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Coupons.Entities;
+
+///
+/// 新客有礼券规则。
+///
+public sealed class NewCustomerCouponRule : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 券规则场景。
+ ///
+ public NewCustomerCouponScene Scene { get; set; } = NewCustomerCouponScene.Welcome;
+
+ ///
+ /// 券类型。
+ ///
+ public NewCustomerCouponType CouponType { get; set; } = NewCustomerCouponType.AmountOff;
+
+ ///
+ /// 面值或折扣值。
+ ///
+ public decimal? Value { get; set; }
+
+ ///
+ /// 使用门槛。
+ ///
+ public decimal? MinimumSpend { get; set; }
+
+ ///
+ /// 有效期天数。
+ ///
+ public int ValidDays { get; set; }
+
+ ///
+ /// 排序值(同场景内递增)。
+ ///
+ public int SortOrder { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGiftSetting.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGiftSetting.cs
new file mode 100644
index 0000000..d1c633a
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGiftSetting.cs
@@ -0,0 +1,45 @@
+using TakeoutSaaS.Domain.Coupons.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Coupons.Entities;
+
+///
+/// 新客有礼门店配置。
+///
+public sealed class NewCustomerGiftSetting : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 是否开启新客礼包。
+ ///
+ public bool GiftEnabled { get; set; }
+
+ ///
+ /// 礼包类型。
+ ///
+ public NewCustomerGiftType GiftType { get; set; } = NewCustomerGiftType.Coupon;
+
+ ///
+ /// 首单直减金额。
+ ///
+ public decimal? DirectReduceAmount { get; set; }
+
+ ///
+ /// 首单直减门槛金额。
+ ///
+ public decimal? DirectMinimumSpend { get; set; }
+
+ ///
+ /// 是否开启老带新分享。
+ ///
+ public bool InviteEnabled { get; set; }
+
+ ///
+ /// 分享渠道(JSON)。
+ ///
+ public string ShareChannelsJson { get; set; } = "[]";
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGrowthRecord.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGrowthRecord.cs
new file mode 100644
index 0000000..6d84246
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerGrowthRecord.cs
@@ -0,0 +1,44 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Coupons.Entities;
+
+///
+/// 新客成长记录。
+///
+public sealed class NewCustomerGrowthRecord : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 顾客业务唯一键。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 顾客展示名。
+ ///
+ public string? CustomerName { get; set; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; set; }
+
+ ///
+ /// 礼包领取时间。
+ ///
+ public DateTime? GiftClaimedAt { get; set; }
+
+ ///
+ /// 首单时间。
+ ///
+ public DateTime? FirstOrderAt { get; set; }
+
+ ///
+ /// 渠道来源。
+ ///
+ public string? SourceChannel { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerInviteRecord.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerInviteRecord.cs
new file mode 100644
index 0000000..189423d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/NewCustomerInviteRecord.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Domain.Coupons.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Coupons.Entities;
+
+///
+/// 新客邀请记录。
+///
+public sealed class NewCustomerInviteRecord : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 邀请人展示名。
+ ///
+ public string InviterName { get; set; } = string.Empty;
+
+ ///
+ /// 被邀请人展示名。
+ ///
+ public string InviteeName { get; set; } = string.Empty;
+
+ ///
+ /// 邀请时间。
+ ///
+ public DateTime InviteTime { get; set; }
+
+ ///
+ /// 订单状态。
+ ///
+ public NewCustomerInviteOrderStatus OrderStatus { get; set; } = NewCustomerInviteOrderStatus.PendingOrder;
+
+ ///
+ /// 奖励状态。
+ ///
+ public NewCustomerInviteRewardStatus RewardStatus { get; set; } = NewCustomerInviteRewardStatus.Pending;
+
+ ///
+ /// 奖励发放时间。
+ ///
+ public DateTime? RewardIssuedAt { get; set; }
+
+ ///
+ /// 邀请来源渠道。
+ ///
+ public string? SourceChannel { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponScene.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponScene.cs
new file mode 100644
index 0000000..64164bb
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponScene.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Coupons.Enums;
+
+///
+/// 新客有礼券规则场景。
+///
+public enum NewCustomerCouponScene
+{
+ ///
+ /// 新客礼包。
+ ///
+ Welcome = 1,
+
+ ///
+ /// 邀请人奖励。
+ ///
+ InviterReward = 2,
+
+ ///
+ /// 被邀请人奖励。
+ ///
+ InviteeReward = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponType.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponType.cs
new file mode 100644
index 0000000..3b42cf8
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerCouponType.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Coupons.Enums;
+
+///
+/// 新客有礼券类型。
+///
+public enum NewCustomerCouponType
+{
+ ///
+ /// 满减券。
+ ///
+ AmountOff = 1,
+
+ ///
+ /// 折扣券。
+ ///
+ Discount = 2,
+
+ ///
+ /// 免配送费券。
+ ///
+ FreeShipping = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerGiftType.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerGiftType.cs
new file mode 100644
index 0000000..1dc50c1
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerGiftType.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Coupons.Enums;
+
+///
+/// 新客礼包类型。
+///
+public enum NewCustomerGiftType
+{
+ ///
+ /// 优惠券包。
+ ///
+ Coupon = 1,
+
+ ///
+ /// 首单直减。
+ ///
+ Direct = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteOrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteOrderStatus.cs
new file mode 100644
index 0000000..1b700ec
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteOrderStatus.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Coupons.Enums;
+
+///
+/// 邀请记录订单状态。
+///
+public enum NewCustomerInviteOrderStatus
+{
+ ///
+ /// 待下单。
+ ///
+ PendingOrder = 1,
+
+ ///
+ /// 已下单。
+ ///
+ Ordered = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteRewardStatus.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteRewardStatus.cs
new file mode 100644
index 0000000..802761d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/NewCustomerInviteRewardStatus.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Coupons.Enums;
+
+///
+/// 邀请奖励发放状态。
+///
+public enum NewCustomerInviteRewardStatus
+{
+ ///
+ /// 待触发。
+ ///
+ Pending = 1,
+
+ ///
+ /// 已发放。
+ ///
+ Issued = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/INewCustomerGiftRepository.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/INewCustomerGiftRepository.cs
new file mode 100644
index 0000000..6898271
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/INewCustomerGiftRepository.cs
@@ -0,0 +1,113 @@
+using TakeoutSaaS.Domain.Coupons.Entities;
+
+namespace TakeoutSaaS.Domain.Coupons.Repositories;
+
+///
+/// 新客有礼仓储契约。
+///
+public interface INewCustomerGiftRepository
+{
+ ///
+ /// 查询门店配置。
+ ///
+ Task FindSettingByStoreIdAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增门店配置。
+ ///
+ Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新门店配置。
+ ///
+ Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询门店全部券规则。
+ ///
+ Task> GetCouponRulesByStoreIdAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 替换门店券规则集合。
+ ///
+ Task ReplaceCouponRulesAsync(
+ long tenantId,
+ long storeId,
+ IReadOnlyCollection entities,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 分页查询邀请记录。
+ ///
+ Task<(IReadOnlyList Items, int TotalCount)> GetInviteRecordsAsync(
+ long tenantId,
+ long storeId,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增邀请记录。
+ ///
+ Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按业务键查询成长记录。
+ ///
+ Task FindGrowthRecordByCustomerKeyAsync(
+ long tenantId,
+ long storeId,
+ string customerKey,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增成长记录。
+ ///
+ Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新成长记录。
+ ///
+ Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 统计时间范围内注册新客数。
+ ///
+ Task CountRegisteredCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 统计时间范围内礼包已领取数。
+ ///
+ Task CountGiftClaimedCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 统计时间范围内首单完成数。
+ ///
+ Task CountFirstOrderedCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 持久化变更。
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index ac8502e..d6a3a4b 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -46,6 +46,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index c148a8a..41091f5 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -354,6 +354,22 @@ public sealed class TakeoutAppDbContext(
///
public DbSet PromotionCampaigns => Set();
///
+ /// 新客有礼配置。
+ ///
+ public DbSet NewCustomerGiftSettings => Set();
+ ///
+ /// 新客有礼券规则。
+ ///
+ public DbSet NewCustomerCouponRules => Set();
+ ///
+ /// 新客邀请记录。
+ ///
+ public DbSet NewCustomerInviteRecords => Set();
+ ///
+ /// 新客成长记录。
+ ///
+ public DbSet NewCustomerGrowthRecords => Set();
+ ///
/// 会员档案。
///
public DbSet MemberProfiles => Set();
@@ -520,6 +536,10 @@ public sealed class TakeoutAppDbContext(
ConfigureCouponTemplate(modelBuilder.Entity());
ConfigureCoupon(modelBuilder.Entity());
ConfigurePromotionCampaign(modelBuilder.Entity());
+ ConfigureNewCustomerGiftSetting(modelBuilder.Entity());
+ ConfigureNewCustomerCouponRule(modelBuilder.Entity());
+ ConfigureNewCustomerInviteRecord(modelBuilder.Entity());
+ ConfigureNewCustomerGrowthRecord(modelBuilder.Entity());
ConfigureMemberProfile(modelBuilder.Entity());
ConfigureMemberTier(modelBuilder.Entity());
ConfigureMemberPointLedger(modelBuilder.Entity());
@@ -1619,6 +1639,59 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.BannerUrl).HasMaxLength(512);
}
+ private static void ConfigureNewCustomerGiftSetting(EntityTypeBuilder builder)
+ {
+ builder.ToTable("new_customer_gift_settings");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.GiftType).HasConversion();
+ builder.Property(x => x.DirectReduceAmount).HasPrecision(18, 2);
+ builder.Property(x => x.DirectMinimumSpend).HasPrecision(18, 2);
+ builder.Property(x => x.ShareChannelsJson).HasColumnType("text").IsRequired();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
+ }
+
+ private static void ConfigureNewCustomerCouponRule(EntityTypeBuilder builder)
+ {
+ builder.ToTable("new_customer_coupon_rules");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.Scene).HasConversion();
+ builder.Property(x => x.CouponType).HasConversion();
+ builder.Property(x => x.Value).HasPrecision(18, 2);
+ builder.Property(x => x.MinimumSpend).HasPrecision(18, 2);
+ builder.Property(x => x.ValidDays).IsRequired();
+ builder.Property(x => x.SortOrder).IsRequired();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Scene, x.SortOrder });
+ }
+
+ private static void ConfigureNewCustomerInviteRecord(EntityTypeBuilder builder)
+ {
+ builder.ToTable("new_customer_invite_records");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.InviterName).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.InviteeName).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.InviteTime).IsRequired();
+ builder.Property(x => x.OrderStatus).HasConversion();
+ builder.Property(x => x.RewardStatus).HasConversion();
+ builder.Property(x => x.SourceChannel).HasMaxLength(32);
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InviteTime });
+ }
+
+ private static void ConfigureNewCustomerGrowthRecord(EntityTypeBuilder builder)
+ {
+ builder.ToTable("new_customer_growth_records");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.CustomerKey).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.CustomerName).HasMaxLength(64);
+ builder.Property(x => x.RegisteredAt).IsRequired();
+ builder.Property(x => x.SourceChannel).HasMaxLength(32);
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.CustomerKey }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RegisteredAt });
+ }
+
private static void ConfigureMemberProfile(EntityTypeBuilder builder)
{
builder.ToTable("member_profiles");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfNewCustomerGiftRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfNewCustomerGiftRepository.cs
new file mode 100644
index 0000000..ec5aa84
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfNewCustomerGiftRepository.cs
@@ -0,0 +1,200 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Coupons.Entities;
+using TakeoutSaaS.Domain.Coupons.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 新客有礼仓储 EF Core 实现。
+///
+public sealed class EfNewCustomerGiftRepository(TakeoutAppDbContext context) : INewCustomerGiftRepository
+{
+ ///
+ public Task FindSettingByStoreIdAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGiftSettings
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGiftSettings.AddAsync(entity, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
+ {
+ context.NewCustomerGiftSettings.Update(entity);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task> GetCouponRulesByStoreIdAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default)
+ {
+ return await context.NewCustomerCouponRules
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId)
+ .OrderBy(item => item.Scene)
+ .ThenBy(item => item.SortOrder)
+ .ThenBy(item => item.Id)
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public async Task ReplaceCouponRulesAsync(
+ long tenantId,
+ long storeId,
+ IReadOnlyCollection entities,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await context.NewCustomerCouponRules
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId)
+ .ToListAsync(cancellationToken);
+
+ if (existing.Count > 0)
+ {
+ context.NewCustomerCouponRules.RemoveRange(existing);
+ }
+
+ if (entities.Count > 0)
+ {
+ await context.NewCustomerCouponRules.AddRangeAsync(entities, cancellationToken);
+ }
+ }
+
+ ///
+ public async Task<(IReadOnlyList Items, int TotalCount)> GetInviteRecordsAsync(
+ long tenantId,
+ long storeId,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
+
+ var query = context.NewCustomerInviteRecords
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId);
+
+ var totalCount = await query.CountAsync(cancellationToken);
+ if (totalCount == 0)
+ {
+ return ([], 0);
+ }
+
+ var items = await query
+ .OrderByDescending(item => item.InviteTime)
+ .ThenByDescending(item => item.Id)
+ .Skip((normalizedPage - 1) * normalizedPageSize)
+ .Take(normalizedPageSize)
+ .ToListAsync(cancellationToken);
+
+ return (items, totalCount);
+ }
+
+ ///
+ public Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerInviteRecords.AddAsync(entity, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task FindGrowthRecordByCustomerKeyAsync(
+ long tenantId,
+ long storeId,
+ string customerKey,
+ CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGrowthRecords
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.CustomerKey == customerKey)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGrowthRecords.AddAsync(entity, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
+ {
+ context.NewCustomerGrowthRecords.Update(entity);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task CountRegisteredCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGrowthRecords
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.RegisteredAt >= startAt &&
+ item.RegisteredAt < endAt)
+ .CountAsync(cancellationToken);
+ }
+
+ ///
+ public Task CountGiftClaimedCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGrowthRecords
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.RegisteredAt >= startAt &&
+ item.RegisteredAt < endAt &&
+ item.GiftClaimedAt != null)
+ .CountAsync(cancellationToken);
+ }
+
+ ///
+ public Task CountFirstOrderedCustomersAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken = default)
+ {
+ return context.NewCustomerGrowthRecords
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.RegisteredAt >= startAt &&
+ item.RegisteredAt < endAt &&
+ item.FirstOrderAt != null)
+ .CountAsync(cancellationToken);
+ }
+
+ ///
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302065925_AddNewCustomerGiftModule.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302065925_AddNewCustomerGiftModule.Designer.cs
new file mode 100644
index 0000000..9861460
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302065925_AddNewCustomerGiftModule.Designer.cs
@@ -0,0 +1,9190 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20260302065925_AddNewCustomerGiftModule")]
+ partial class AddNewCustomerGiftModule
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Delivered");
+
+ b.ToTable("InboxState");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.Property("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Headers")
+ .HasColumnType("text");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("SequenceNumber");
+
+ b.HasIndex("EnqueueTime");
+
+ b.HasIndex("ExpirationTime");
+
+ b.HasIndex("OutboxId", "SequenceNumber")
+ .IsUnique();
+
+ b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
+ .IsUnique();
+
+ b.ToTable("OutboxMessage");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
+ {
+ b.Property("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("OutboxId");
+
+ b.HasIndex("Created");
+
+ b.ToTable("OutboxState");
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasComment("告警级别。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "Severity");
+
+ b.ToTable("metric_alert_rules", null, t =>
+ {
+ t.HasComment("指标告警规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("指标编码。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DefaultAggregation")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("默认聚合方式。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("说明。");
+
+ b.Property("DimensionsJson")
+ .HasColumnType("text")
+ .HasComment("维度描述 JSON。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("指标名称。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("metric_definitions", null, t =>
+ {
+ t.HasComment("指标定义,描述可观测的数据点。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DimensionKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("维度键(JSON)。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("指标定义 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("Value")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasComment("数值。");
+
+ b.Property("WindowEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口结束。");
+
+ b.Property("WindowStart")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口开始。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd")
+ .IsUnique();
+
+ b.ToTable("metric_snapshots", null, t =>
+ {
+ t.HasComment("指标快照,用于大盘展示。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("券码或序列号。");
+
+ b.Property("CouponTemplateId")
+ .HasColumnType("bigint")
+ .HasComment("模板标识。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("ExpireAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("到期时间。");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发放时间。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("订单 ID(已使用时记录)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("使用时间。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("归属用户。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("coupons", null, t =>
+ {
+ t.HasComment("用户领取的券。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowStack")
+ .HasColumnType("boolean")
+ .HasComment("是否允许叠加其他优惠。");
+
+ b.Property("ChannelsJson")
+ .HasColumnType("text")
+ .HasComment("发放渠道(JSON)。");
+
+ b.Property("ClaimedQuantity")
+ .HasColumnType("integer")
+ .HasComment("已领取数量。");
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .HasComment("券类型。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("备注。");
+
+ b.Property("DiscountCap")
+ .HasColumnType("numeric")
+ .HasComment("折扣上限(针对折扣券)。");
+
+ b.Property("MinimumSpend")
+ .HasColumnType("numeric")
+ .HasComment("最低消费门槛。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("模板名称。");
+
+ b.Property("PerUserLimit")
+ .HasColumnType("integer")
+ .HasComment("每位用户可领取上限。");
+
+ b.Property("ProductScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用品类或商品范围(JSON)。");
+
+ b.Property("RelativeValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效天数(相对发放时间)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("StoreScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用门店 ID 集合(JSON)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TotalQuantity")
+ .HasColumnType("integer")
+ .HasComment("总发放数量上限。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("ValidFrom")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用开始时间。");
+
+ b.Property("ValidTo")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用结束时间。");
+
+ b.Property("Value")
+ .HasColumnType("numeric")
+ .HasComment("面值或折扣额度。");
+
+ b.HasKey("Id");
+
+ b.ToTable("coupon_templates", null, t =>
+ {
+ t.HasComment("优惠券模板。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerCouponRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .HasComment("券类型。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("MinimumSpend")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("使用门槛。");
+
+ b.Property("Scene")
+ .HasColumnType("integer")
+ .HasComment("券规则场景。");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasComment("排序值(同场景内递增)。");
+
+ b.Property("StoreId")
+ .HasColumnType("bigint")
+ .HasComment("门店 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("ValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效期天数。");
+
+ b.Property("Value")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("面值或折扣值。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "StoreId", "Scene", "SortOrder");
+
+ b.ToTable("new_customer_coupon_rules", null, t =>
+ {
+ t.HasComment("新客有礼券规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerGiftSetting", b =>
+ {
+ b.Property