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,489 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 新客有礼详情请求。
/// </summary>
public sealed class NewCustomerDetailRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 邀请记录页码。
/// </summary>
public int RecordPage { get; set; } = 1;
/// <summary>
/// 邀请记录每页条数。
/// </summary>
public int RecordPageSize { get; set; } = 10;
}
/// <summary>
/// 新客有礼配置保存请求。
/// </summary>
public sealed class SaveNewCustomerSettingsRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; set; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道wechat_friend/moments/sms
/// </summary>
public List<string> ShareChannels { get; set; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> WelcomeCoupons { get; set; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> InviterCoupons { get; set; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> InviteeCoupons { get; set; } = [];
}
/// <summary>
/// 新客邀请记录分页请求。
/// </summary>
public sealed class NewCustomerInviteRecordListRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 写入新客邀请记录请求。
/// </summary>
public sealed class WriteNewCustomerInviteRecordRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; set; }
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; set; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; set; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 写入新客成长记录请求。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; set; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 保存优惠券规则请求项。
/// </summary>
public sealed class NewCustomerSaveCouponRuleRequest
{
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
}
/// <summary>
/// 新客有礼详情响应。
/// </summary>
public sealed class NewCustomerDetailResponse
{
/// <summary>
/// 配置详情。
/// </summary>
public NewCustomerSettingsResponse Settings { get; set; } = new();
/// <summary>
/// 统计数据。
/// </summary>
public NewCustomerStatsResponse Stats { get; set; } = new();
/// <summary>
/// 邀请记录分页。
/// </summary>
public NewCustomerInviteRecordListResultResponse InviteRecords { get; set; } = new();
}
/// <summary>
/// 新客有礼配置响应。
/// </summary>
public sealed class NewCustomerSettingsResponse
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; set; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道wechat_friend/moments/sms
/// </summary>
public List<string> ShareChannels { get; set; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> WelcomeCoupons { get; set; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> InviterCoupons { get; set; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> InviteeCoupons { get; set; } = [];
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 新客有礼统计响应。
/// </summary>
public sealed class NewCustomerStatsResponse
{
/// <summary>
/// 本月新客数。
/// </summary>
public int MonthlyNewCustomers { get; set; }
/// <summary>
/// 较上月增长人数。
/// </summary>
public int MonthlyGrowthCount { get; set; }
/// <summary>
/// 较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; set; }
/// <summary>
/// 本月礼包领取率(百分比)。
/// </summary>
public decimal GiftClaimRate { get; set; }
/// <summary>
/// 本月礼包已领取人数。
/// </summary>
public int GiftClaimedCount { get; set; }
/// <summary>
/// 本月首单转化率(百分比)。
/// </summary>
public decimal FirstOrderConversionRate { get; set; }
/// <summary>
/// 本月首单完成人数。
/// </summary>
public int FirstOrderedCount { get; set; }
}
/// <summary>
/// 邀请记录分页结果响应。
/// </summary>
public sealed class NewCustomerInviteRecordListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<NewCustomerInviteRecordResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 新客邀请记录响应。
/// </summary>
public sealed class NewCustomerInviteRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string InviteTime { get; set; } = string.Empty;
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; set; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; set; } = "pending";
/// <summary>
/// 奖励发放时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? RewardIssuedAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 新客成长记录响应。
/// </summary>
public sealed class NewCustomerGrowthRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 礼包领取时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? FirstOrderAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 新客券规则响应。
/// </summary>
public sealed class NewCustomerCouponRuleResponse
{
/// <summary>
/// 规则 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 场景welcome/inviter/invitee
/// </summary>
public string Scene { get; set; } = "welcome";
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
}

View File

@@ -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;
/// <summary>
/// 营销中心新客有礼管理。
/// </summary>
[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";
/// <summary>
/// 获取新客有礼详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerDetailResponse>> 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<NewCustomerDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存新客有礼配置。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerSettingsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerSettingsResponse>> 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<NewCustomerSettingsResponse>.Ok(MapSettings(result));
}
/// <summary>
/// 获取新客邀请记录分页。
/// </summary>
[HttpGet("invite-record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerInviteRecordListResultResponse>> 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<NewCustomerInviteRecordListResultResponse>.Ok(MapInviteRecordList(result));
}
/// <summary>
/// 写入新客邀请记录。
/// </summary>
[HttpPost("invite-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerInviteRecordResponse>> 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<NewCustomerInviteRecordResponse>.Ok(MapInviteRecord(result));
}
/// <summary>
/// 写入新客成长记录。
/// </summary>
[HttpPost("growth-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerGrowthRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerGrowthRecordResponse>> 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<NewCustomerGrowthRecordResponse>.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);
}
}

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;
}

View File

@@ -0,0 +1,45 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客有礼券规则。
/// </summary>
public sealed class NewCustomerCouponRule : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 券规则场景。
/// </summary>
public NewCustomerCouponScene Scene { get; set; } = NewCustomerCouponScene.Welcome;
/// <summary>
/// 券类型。
/// </summary>
public NewCustomerCouponType CouponType { get; set; } = NewCustomerCouponType.AmountOff;
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
/// <summary>
/// 排序值(同场景内递增)。
/// </summary>
public int SortOrder { get; set; }
}

View File

@@ -0,0 +1,45 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客有礼门店配置。
/// </summary>
public sealed class NewCustomerGiftSetting : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型。
/// </summary>
public NewCustomerGiftType GiftType { get; set; } = NewCustomerGiftType.Coupon;
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道JSON
/// </summary>
public string ShareChannelsJson { get; set; } = "[]";
}

View File

@@ -0,0 +1,44 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客成长记录。
/// </summary>
public sealed class NewCustomerGrowthRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; set; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; set; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; set; }
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客邀请记录。
/// </summary>
public sealed class NewCustomerInviteRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; set; }
/// <summary>
/// 订单状态。
/// </summary>
public NewCustomerInviteOrderStatus OrderStatus { get; set; } = NewCustomerInviteOrderStatus.PendingOrder;
/// <summary>
/// 奖励状态。
/// </summary>
public NewCustomerInviteRewardStatus RewardStatus { get; set; } = NewCustomerInviteRewardStatus.Pending;
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; set; }
/// <summary>
/// 邀请来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客有礼券规则场景。
/// </summary>
public enum NewCustomerCouponScene
{
/// <summary>
/// 新客礼包。
/// </summary>
Welcome = 1,
/// <summary>
/// 邀请人奖励。
/// </summary>
InviterReward = 2,
/// <summary>
/// 被邀请人奖励。
/// </summary>
InviteeReward = 3
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客有礼券类型。
/// </summary>
public enum NewCustomerCouponType
{
/// <summary>
/// 满减券。
/// </summary>
AmountOff = 1,
/// <summary>
/// 折扣券。
/// </summary>
Discount = 2,
/// <summary>
/// 免配送费券。
/// </summary>
FreeShipping = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客礼包类型。
/// </summary>
public enum NewCustomerGiftType
{
/// <summary>
/// 优惠券包。
/// </summary>
Coupon = 1,
/// <summary>
/// 首单直减。
/// </summary>
Direct = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 邀请记录订单状态。
/// </summary>
public enum NewCustomerInviteOrderStatus
{
/// <summary>
/// 待下单。
/// </summary>
PendingOrder = 1,
/// <summary>
/// 已下单。
/// </summary>
Ordered = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 邀请奖励发放状态。
/// </summary>
public enum NewCustomerInviteRewardStatus
{
/// <summary>
/// 待触发。
/// </summary>
Pending = 1,
/// <summary>
/// 已发放。
/// </summary>
Issued = 2
}

View File

@@ -0,0 +1,113 @@
using TakeoutSaaS.Domain.Coupons.Entities;
namespace TakeoutSaaS.Domain.Coupons.Repositories;
/// <summary>
/// 新客有礼仓储契约。
/// </summary>
public interface INewCustomerGiftRepository
{
/// <summary>
/// 查询门店配置。
/// </summary>
Task<NewCustomerGiftSetting?> FindSettingByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增门店配置。
/// </summary>
Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新门店配置。
/// </summary>
Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询门店全部券规则。
/// </summary>
Task<IReadOnlyList<NewCustomerCouponRule>> GetCouponRulesByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 替换门店券规则集合。
/// </summary>
Task ReplaceCouponRulesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<NewCustomerCouponRule> entities,
CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询邀请记录。
/// </summary>
Task<(IReadOnlyList<NewCustomerInviteRecord> Items, int TotalCount)> GetInviteRecordsAsync(
long tenantId,
long storeId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增邀请记录。
/// </summary>
Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 按业务键查询成长记录。
/// </summary>
Task<NewCustomerGrowthRecord?> FindGrowthRecordByCustomerKeyAsync(
long tenantId,
long storeId,
string customerKey,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增成长记录。
/// </summary>
Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新成长记录。
/// </summary>
Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内注册新客数。
/// </summary>
Task<int> CountRegisteredCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内礼包已领取数。
/// </summary>
Task<int> CountGiftClaimedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内首单完成数。
/// </summary>
Task<int> CountFirstOrderedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -46,6 +46,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IStoreRepository, EfStoreRepository>(); services.AddScoped<IStoreRepository, EfStoreRepository>();
services.AddScoped<IProductRepository, EfProductRepository>(); services.AddScoped<IProductRepository, EfProductRepository>();
services.AddScoped<ICouponRepository, EfCouponRepository>(); services.AddScoped<ICouponRepository, EfCouponRepository>();
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>(); services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>(); services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>(); services.AddScoped<IPaymentRepository, EfPaymentRepository>();

View File

@@ -354,6 +354,22 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<PromotionCampaign> PromotionCampaigns => Set<PromotionCampaign>(); public DbSet<PromotionCampaign> PromotionCampaigns => Set<PromotionCampaign>();
/// <summary> /// <summary>
/// 新客有礼配置。
/// </summary>
public DbSet<NewCustomerGiftSetting> NewCustomerGiftSettings => Set<NewCustomerGiftSetting>();
/// <summary>
/// 新客有礼券规则。
/// </summary>
public DbSet<NewCustomerCouponRule> NewCustomerCouponRules => Set<NewCustomerCouponRule>();
/// <summary>
/// 新客邀请记录。
/// </summary>
public DbSet<NewCustomerInviteRecord> NewCustomerInviteRecords => Set<NewCustomerInviteRecord>();
/// <summary>
/// 新客成长记录。
/// </summary>
public DbSet<NewCustomerGrowthRecord> NewCustomerGrowthRecords => Set<NewCustomerGrowthRecord>();
/// <summary>
/// 会员档案。 /// 会员档案。
/// </summary> /// </summary>
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>(); public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
@@ -520,6 +536,10 @@ public sealed class TakeoutAppDbContext(
ConfigureCouponTemplate(modelBuilder.Entity<CouponTemplate>()); ConfigureCouponTemplate(modelBuilder.Entity<CouponTemplate>());
ConfigureCoupon(modelBuilder.Entity<Coupon>()); ConfigureCoupon(modelBuilder.Entity<Coupon>());
ConfigurePromotionCampaign(modelBuilder.Entity<PromotionCampaign>()); ConfigurePromotionCampaign(modelBuilder.Entity<PromotionCampaign>());
ConfigureNewCustomerGiftSetting(modelBuilder.Entity<NewCustomerGiftSetting>());
ConfigureNewCustomerCouponRule(modelBuilder.Entity<NewCustomerCouponRule>());
ConfigureNewCustomerInviteRecord(modelBuilder.Entity<NewCustomerInviteRecord>());
ConfigureNewCustomerGrowthRecord(modelBuilder.Entity<NewCustomerGrowthRecord>());
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>()); ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
ConfigureMemberTier(modelBuilder.Entity<MemberTier>()); ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>()); ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
@@ -1619,6 +1639,59 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.BannerUrl).HasMaxLength(512); builder.Property(x => x.BannerUrl).HasMaxLength(512);
} }
private static void ConfigureNewCustomerGiftSetting(EntityTypeBuilder<NewCustomerGiftSetting> builder)
{
builder.ToTable("new_customer_gift_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.GiftType).HasConversion<int>();
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<NewCustomerCouponRule> builder)
{
builder.ToTable("new_customer_coupon_rules");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Scene).HasConversion<int>();
builder.Property(x => x.CouponType).HasConversion<int>();
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<NewCustomerInviteRecord> 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<int>();
builder.Property(x => x.RewardStatus).HasConversion<int>();
builder.Property(x => x.SourceChannel).HasMaxLength(32);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InviteTime });
}
private static void ConfigureNewCustomerGrowthRecord(EntityTypeBuilder<NewCustomerGrowthRecord> 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<MemberProfile> builder) private static void ConfigureMemberProfile(EntityTypeBuilder<MemberProfile> builder)
{ {
builder.ToTable("member_profiles"); builder.ToTable("member_profiles");

View File

@@ -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;
/// <summary>
/// 新客有礼仓储 EF Core 实现。
/// </summary>
public sealed class EfNewCustomerGiftRepository(TakeoutAppDbContext context) : INewCustomerGiftRepository
{
/// <inheritdoc />
public Task<NewCustomerGiftSetting?> FindSettingByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGiftSettings
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerGiftSettings.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
{
context.NewCustomerGiftSettings.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<NewCustomerCouponRule>> 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);
}
/// <inheritdoc />
public async Task ReplaceCouponRulesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<NewCustomerCouponRule> 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);
}
}
/// <inheritdoc />
public async Task<(IReadOnlyList<NewCustomerInviteRecord> 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);
}
/// <inheritdoc />
public Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerInviteRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task<NewCustomerGrowthRecord?> 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);
}
/// <inheritdoc />
public Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
{
context.NewCustomerGrowthRecords.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,168 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNewCustomerGiftModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "new_customer_coupon_rules",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
Scene = table.Column<int>(type: "integer", nullable: false, comment: "券规则场景。"),
CouponType = table.Column<int>(type: "integer", nullable: false, comment: "券类型。"),
Value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "面值或折扣值。"),
MinimumSpend = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "使用门槛。"),
ValidDays = table.Column<int>(type: "integer", nullable: false, comment: "有效期天数。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, comment: "排序值(同场景内递增)。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_new_customer_coupon_rules", x => x.Id);
},
comment: "新客有礼券规则。");
migrationBuilder.CreateTable(
name: "new_customer_gift_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
GiftEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否开启新客礼包。"),
GiftType = table.Column<int>(type: "integer", nullable: false, comment: "礼包类型。"),
DirectReduceAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "首单直减金额。"),
DirectMinimumSpend = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "首单直减门槛金额。"),
InviteEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否开启老带新分享。"),
ShareChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "分享渠道JSON。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_new_customer_gift_settings", x => x.Id);
},
comment: "新客有礼门店配置。");
migrationBuilder.CreateTable(
name: "new_customer_growth_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
CustomerKey = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "顾客业务唯一键。"),
CustomerName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "顾客展示名。"),
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "注册时间。"),
GiftClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "礼包领取时间。"),
FirstOrderAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "首单时间。"),
SourceChannel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "渠道来源。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_new_customer_growth_records", x => x.Id);
},
comment: "新客成长记录。");
migrationBuilder.CreateTable(
name: "new_customer_invite_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
InviterName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "邀请人展示名。"),
InviteeName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "被邀请人展示名。"),
InviteTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "邀请时间。"),
OrderStatus = table.Column<int>(type: "integer", nullable: false, comment: "订单状态。"),
RewardStatus = table.Column<int>(type: "integer", nullable: false, comment: "奖励状态。"),
RewardIssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "奖励发放时间。"),
SourceChannel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "邀请来源渠道。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_new_customer_invite_records", x => x.Id);
},
comment: "新客邀请记录。");
migrationBuilder.CreateIndex(
name: "IX_new_customer_coupon_rules_TenantId_StoreId_Scene_SortOrder",
table: "new_customer_coupon_rules",
columns: new[] { "TenantId", "StoreId", "Scene", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_new_customer_gift_settings_TenantId_StoreId",
table: "new_customer_gift_settings",
columns: new[] { "TenantId", "StoreId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_new_customer_growth_records_TenantId_StoreId_CustomerKey",
table: "new_customer_growth_records",
columns: new[] { "TenantId", "StoreId", "CustomerKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_new_customer_growth_records_TenantId_StoreId_RegisteredAt",
table: "new_customer_growth_records",
columns: new[] { "TenantId", "StoreId", "RegisteredAt" });
migrationBuilder.CreateIndex(
name: "IX_new_customer_invite_records_TenantId_StoreId_InviteTime",
table: "new_customer_invite_records",
columns: new[] { "TenantId", "StoreId", "InviteTime" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "new_customer_coupon_rules");
migrationBuilder.DropTable(
name: "new_customer_gift_settings");
migrationBuilder.DropTable(
name: "new_customer_growth_records");
migrationBuilder.DropTable(
name: "new_customer_invite_records");
}
}
}

View File

@@ -604,6 +604,328 @@ namespace TakeoutSaaS.Infrastructure.Migrations
}); });
}); });
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerCouponRule", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("CouponType")
.HasColumnType("integer")
.HasComment("券类型。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal?>("MinimumSpend")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("使用门槛。");
b.Property<int>("Scene")
.HasColumnType("integer")
.HasComment("券规则场景。");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasComment("排序值(同场景内递增)。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<int>("ValidDays")
.HasColumnType("integer")
.HasComment("有效期天数。");
b.Property<decimal?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal?>("DirectMinimumSpend")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("首单直减门槛金额。");
b.Property<decimal?>("DirectReduceAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("首单直减金额。");
b.Property<bool>("GiftEnabled")
.HasColumnType("boolean")
.HasComment("是否开启新客礼包。");
b.Property<int>("GiftType")
.HasColumnType("integer")
.HasComment("礼包类型。");
b.Property<bool>("InviteEnabled")
.HasColumnType("boolean")
.HasComment("是否开启老带新分享。");
b.Property<string>("ShareChannelsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("分享渠道JSON。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId")
.IsUnique();
b.ToTable("new_customer_gift_settings", null, t =>
{
t.HasComment("新客有礼门店配置。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerGrowthRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<string>("CustomerKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("顾客业务唯一键。");
b.Property<string>("CustomerName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("顾客展示名。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<DateTime?>("FirstOrderAt")
.HasColumnType("timestamp with time zone")
.HasComment("首单时间。");
b.Property<DateTime?>("GiftClaimedAt")
.HasColumnType("timestamp with time zone")
.HasComment("礼包领取时间。");
b.Property<DateTime>("RegisteredAt")
.HasColumnType("timestamp with time zone")
.HasComment("注册时间。");
b.Property<string>("SourceChannel")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("渠道来源。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "CustomerKey")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "RegisteredAt");
b.ToTable("new_customer_growth_records", null, t =>
{
t.HasComment("新客成长记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerInviteRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<DateTime>("InviteTime")
.HasColumnType("timestamp with time zone")
.HasComment("邀请时间。");
b.Property<string>("InviteeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("被邀请人展示名。");
b.Property<string>("InviterName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("邀请人展示名。");
b.Property<int>("OrderStatus")
.HasColumnType("integer")
.HasComment("订单状态。");
b.Property<DateTime?>("RewardIssuedAt")
.HasColumnType("timestamp with time zone")
.HasComment("奖励发放时间。");
b.Property<int>("RewardStatus")
.HasColumnType("integer")
.HasComment("奖励状态。");
b.Property<string>("SourceChannel")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("邀请来源渠道。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "InviteTime");
b.ToTable("new_customer_invite_records", null, t =>
{
t.HasComment("新客邀请记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b => modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")