feat(marketing): add new customer gift backend module

This commit is contained in:
2026-03-02 15:58:06 +08:00
parent c9e2226b48
commit 6588c85f27
38 changed files with 12525 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 保存新客有礼配置。
/// </summary>
public sealed class SaveNewCustomerSettingsCommand : IRequest<NewCustomerSettingsDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; init; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; init; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; init; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; init; }
/// <summary>
/// 是否开启邀请分享。
/// </summary>
public bool InviteEnabled { get; init; }
/// <summary>
/// 分享渠道。
/// </summary>
public IReadOnlyCollection<string> ShareChannels { get; init; } = [];
/// <summary>
/// 新客礼包券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> WelcomeCoupons { get; init; } = [];
/// <summary>
/// 邀请人奖励券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviterCoupons { get; init; } = [];
/// <summary>
/// 被邀请人奖励券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviteeCoupons { get; init; } = [];
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 写入新客成长记录。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordCommand : IRequest<NewCustomerGrowthRecordDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; init; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; init; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 写入新客邀请记录。
/// </summary>
public sealed class WriteNewCustomerInviteRecordCommand : IRequest<NewCustomerInviteRecordDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; init; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; init; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; init; }
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; init; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; init; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; init; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼券规则 DTO。
/// </summary>
public sealed class NewCustomerCouponRuleDto
{
/// <summary>
/// 规则 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 场景welcome/inviter/invitee
/// </summary>
public string Scene { get; init; } = "welcome";
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; init; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼详情 DTO。
/// </summary>
public sealed class NewCustomerDetailDto
{
/// <summary>
/// 配置详情。
/// </summary>
public NewCustomerSettingsDto Settings { get; init; } = new();
/// <summary>
/// 统计数据。
/// </summary>
public NewCustomerStatsDto Stats { get; init; } = new();
/// <summary>
/// 邀请记录分页结果。
/// </summary>
public NewCustomerInviteRecordListResultDto InviteRecords { get; init; } = new();
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客成长记录 DTO。
/// </summary>
public sealed class NewCustomerGrowthRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 顾客业务键。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; init; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; init; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客邀请记录 DTO。
/// </summary>
public sealed class NewCustomerInviteRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; init; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; init; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; init; }
/// <summary>
/// 状态pending_order/ordered
/// </summary>
public string OrderStatus { get; init; } = "pending_order";
/// <summary>
/// 奖励发放状态pending/issued
/// </summary>
public string RewardStatus { get; init; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; init; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客邀请记录分页结果 DTO。
/// </summary>
public sealed class NewCustomerInviteRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<NewCustomerInviteRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 保存新客有礼券规则输入 DTO。
/// </summary>
public sealed class NewCustomerSaveCouponRuleInputDto
{
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; init; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; init; }
}

View File

@@ -0,0 +1,62 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼配置 DTO。
/// </summary>
public sealed class NewCustomerSettingsDto
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; init; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; init; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; init; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; init; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; init; }
/// <summary>
/// 分享渠道。
/// </summary>
public IReadOnlyList<string> ShareChannels { get; init; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> WelcomeCoupons { get; init; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> InviterCoupons { get; init; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> InviteeCoupons { get; init; } = [];
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼统计 DTO。
/// </summary>
public sealed class NewCustomerStatsDto
{
/// <summary>
/// 本月新客数。
/// </summary>
public int MonthlyNewCustomers { get; init; }
/// <summary>
/// 较上月增长人数。
/// </summary>
public int MonthlyGrowthCount { get; init; }
/// <summary>
/// 较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; init; }
/// <summary>
/// 本月礼包领取率。
/// </summary>
public decimal GiftClaimRate { get; init; }
/// <summary>
/// 本月礼包已领取人数。
/// </summary>
public int GiftClaimedCount { get; init; }
/// <summary>
/// 本月首单转化率。
/// </summary>
public decimal FirstOrderConversionRate { get; init; }
/// <summary>
/// 本月首单完成人数。
/// </summary>
public int FirstOrderedCount { get; init; }
}

View File

@@ -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;
/// <summary>
/// 查询新客有礼详情处理器。
/// </summary>
public sealed class GetNewCustomerDetailQueryHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetNewCustomerDetailQuery, NewCustomerDetailDto>
{
/// <inheritdoc />
public async Task<NewCustomerDetailDto> 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<NewCustomerCouponRule> 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
};
}
}

View File

@@ -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;
/// <summary>
/// 查询新客邀请记录分页处理器。
/// </summary>
public sealed class GetNewCustomerInviteRecordListQueryHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetNewCustomerInviteRecordListQuery, NewCustomerInviteRecordListResultDto>
{
/// <inheritdoc />
public async Task<NewCustomerInviteRecordListResultDto> 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
};
}
}

View File

@@ -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;
/// <summary>
/// 保存新客有礼配置处理器。
/// </summary>
public sealed class SaveNewCustomerSettingsCommandHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveNewCustomerSettingsCommand, NewCustomerSettingsDto>
{
/// <inheritdoc />
public async Task<NewCustomerSettingsDto> 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<NewCustomerCouponRule>(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
};
}
}

View File

@@ -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;
/// <summary>
/// 写入新客成长记录处理器。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordCommandHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<WriteNewCustomerGrowthRecordCommand, NewCustomerGrowthRecordDto>
{
/// <inheritdoc />
public async Task<NewCustomerGrowthRecordDto> 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;
}
}

View File

@@ -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;
/// <summary>
/// 写入新客邀请记录处理器。
/// </summary>
public sealed class WriteNewCustomerInviteRecordCommandHandler(
INewCustomerGiftRepository repository)
: IRequestHandler<WriteNewCustomerInviteRecordCommand, NewCustomerInviteRecordDto>
{
/// <inheritdoc />
public async Task<NewCustomerInviteRecordDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 新客有礼映射与规则校验。
/// </summary>
internal static class NewCustomerMapping
{
private static readonly HashSet<string> 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<string> channels)
{
return JsonSerializer.Serialize(channels, JsonOptions);
}
public static IReadOnlyList<string> DeserializeShareChannels(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
return NormalizeShareChannels(values);
}
public static IReadOnlyList<string> NormalizeShareChannels(IEnumerable<string>? 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<NewCustomerCouponRule> NormalizeCouponRulesForSave(
long storeId,
NewCustomerCouponScene scene,
IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto>? 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);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
/// <summary>
/// 查询新客有礼详情。
/// </summary>
public sealed class GetNewCustomerDetailQuery : IRequest<NewCustomerDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 邀请记录页码。
/// </summary>
public int RecordPage { get; init; } = 1;
/// <summary>
/// 邀请记录每页条数。
/// </summary>
public int RecordPageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
/// <summary>
/// 查询新客邀请记录分页。
/// </summary>
public sealed class GetNewCustomerInviteRecordListQuery : IRequest<NewCustomerInviteRecordListResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}