feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除会员等级命令。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberTierCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long TierId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员日配置命令。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberDaySettingCommand : IRequest<MemberDaySettingDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用会员日。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周几(1-7,对应周一到周日)。
|
||||
/// </summary>
|
||||
public int Weekday { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员日额外折扣(如 9 表示 9 折)。
|
||||
/// </summary>
|
||||
public decimal ExtraDiscountRate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员标签命令。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTagsCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员等级命令。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTierCommand : IRequest<MemberTierDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识(为空时新增)。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; init; } = "user";
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; init; } = "#999999";
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 升降级规则。
|
||||
/// </summary>
|
||||
public MemberTierRuleDto Rule { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 等级权益。
|
||||
/// </summary>
|
||||
public MemberTierBenefitsDto Benefits { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表行 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MobileMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 等级主题色。
|
||||
/// </summary>
|
||||
public string TierColorHex { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近消费时间。
|
||||
/// </summary>
|
||||
public DateTime LastOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值余额。
|
||||
/// </summary>
|
||||
public decimal StoredBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否沉睡会员(用于弱化展示)。
|
||||
/// </summary>
|
||||
public bool IsDormant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberListStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员总数。
|
||||
/// </summary>
|
||||
public int TotalMembers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月新增会员数。
|
||||
/// </summary>
|
||||
public int MonthlyNewMembers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃会员数(近 30 天有消费)。
|
||||
/// </summary>
|
||||
public int ActiveMembers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 沉睡会员数(超过 60 天未消费)。
|
||||
/// </summary>
|
||||
public int DormantMembers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员最近订单 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberRecentOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MobileMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime JoinedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 等级主题色。
|
||||
/// </summary>
|
||||
public string TierColorHex { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值余额。
|
||||
/// </summary>
|
||||
public decimal StoredBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值实充余额。
|
||||
/// </summary>
|
||||
public decimal StoredRechargeBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值赠金余额。
|
||||
/// </summary>
|
||||
public decimal StoredGiftBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberRecentOrderDto> RecentOrders { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表导出 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 升级条件文案。
|
||||
/// </summary>
|
||||
public string ConditionText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 权益摘要。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Perks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 等级会员数。
|
||||
/// </summary>
|
||||
public int MemberCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可删除。
|
||||
/// </summary>
|
||||
public bool CanDelete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 升级规则类型(none/amount/count/both)。
|
||||
/// </summary>
|
||||
public string UpgradeRuleType { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// 升级累计消费门槛。
|
||||
/// </summary>
|
||||
public decimal? UpgradeAmountThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 升级消费次数门槛。
|
||||
/// </summary>
|
||||
public int? UpgradeOrderCountThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 降级观察窗口天数。
|
||||
/// </summary>
|
||||
public int DowngradeWindowDays { get; init; } = 90;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级权益 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierBenefitsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 折扣权益。
|
||||
/// </summary>
|
||||
public MemberTierDiscountBenefitDto Discount { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 积分倍率权益。
|
||||
/// </summary>
|
||||
public MemberTierPointMultiplierBenefitDto PointMultiplier { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 生日特权。
|
||||
/// </summary>
|
||||
public MemberTierBirthdayBenefitDto Birthday { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 每月赠券。
|
||||
/// </summary>
|
||||
public MemberTierMonthlyCouponBenefitDto MonthlyCoupon { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费权益。
|
||||
/// </summary>
|
||||
public MemberTierFreeDeliveryBenefitDto FreeDelivery { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 优先配送。
|
||||
/// </summary>
|
||||
public bool PriorityDeliveryEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 专属客服。
|
||||
/// </summary>
|
||||
public bool ExclusiveServiceEnabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 折扣权益 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierDiscountBenefitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣值(如 9.5)。
|
||||
/// </summary>
|
||||
public decimal? DiscountRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分倍率权益 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierPointMultiplierBenefitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 倍率(如 1.5)。
|
||||
/// </summary>
|
||||
public decimal? Multiplier { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生日特权 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierBirthdayBenefitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否双倍积分。
|
||||
/// </summary>
|
||||
public bool DoublePointsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生日赠券模板 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每月赠券 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierMonthlyCouponBenefitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每月发放日(1-28)。
|
||||
/// </summary>
|
||||
public int GrantDay { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每月赠券模板 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费权益 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierFreeDeliveryBenefitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每月免配送费次数。
|
||||
/// </summary>
|
||||
public int MonthlyFreeTimes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberTierDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 升降级规则。
|
||||
/// </summary>
|
||||
public MemberTierRuleDto Rule { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 等级权益。
|
||||
/// </summary>
|
||||
public MemberTierBenefitsDto Benefits { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否可删除。
|
||||
/// </summary>
|
||||
public bool CanDelete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员日配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberDaySettingDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用会员日。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周几(1-7,对应周一到周日)。
|
||||
/// </summary>
|
||||
public int Weekday { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员日额外折扣(如 9 表示 9 折)。
|
||||
/// </summary>
|
||||
public decimal ExtraDiscountRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券选择项 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberCouponPickerItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 券模板标识。
|
||||
/// </summary>
|
||||
public long CouponTemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最低消费门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示文案。
|
||||
/// </summary>
|
||||
public string DisplayText { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除会员等级命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberTierCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeleteMemberTierCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Handle(DeleteMemberTierCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||
|
||||
if (tier.IsDefault)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "默认等级不允许删除");
|
||||
}
|
||||
|
||||
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||
if (profiles.Any(item => item.MemberTierId == request.TierId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前等级下存在会员,无法删除");
|
||||
}
|
||||
|
||||
await memberRepository.DeleteTierAsync(tier, cancellationToken);
|
||||
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
if (!latestTiers.Any(item => item.IsDefault) && latestTiers.Count > 0)
|
||||
{
|
||||
var fallback = latestTiers
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Id)
|
||||
.First();
|
||||
fallback.IsDefault = true;
|
||||
await memberRepository.UpdateTierAsync(fallback, cancellationToken);
|
||||
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表 CSV 导出查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportMemberCsvQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportMemberCsvQuery, MemberExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberExportDto> Handle(
|
||||
ExportMemberCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new MemberExportDto
|
||||
{
|
||||
FileName = BuildFileName(),
|
||||
FileContentBase64 = string.Empty,
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = MemberCenterSupport.ApplyFilters(
|
||||
context.Aggregates,
|
||||
request.Keyword,
|
||||
request.TierId)
|
||||
.OrderByDescending(item => item.Metrics.LastOrderAt)
|
||||
.ThenByDescending(item => item.MemberId)
|
||||
.ToList();
|
||||
|
||||
var rows = filtered
|
||||
.Select(item => new MemberListItemDto
|
||||
{
|
||||
MemberId = item.MemberId,
|
||||
Name = item.Name,
|
||||
AvatarText = item.AvatarText,
|
||||
AvatarColor = item.AvatarColor,
|
||||
MobileMasked = item.MobileMasked,
|
||||
TierId = item.Tier?.Id,
|
||||
TierName = item.Tier?.Name ?? string.Empty,
|
||||
TierColorHex = item.Tier?.ColorHex ?? "#999999",
|
||||
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||
OrderCount = item.Metrics.TotalOrderCount,
|
||||
LastOrderAt = item.Metrics.LastOrderAt,
|
||||
StoredBalance = item.Profile.StoredBalance,
|
||||
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
|
||||
IsDormant = item.IsDormant
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var csv = MemberCenterSupport.BuildCsv(rows);
|
||||
var payload = Encoding.UTF8.GetPreamble()
|
||||
.Concat(Encoding.UTF8.GetBytes(csv))
|
||||
.ToArray();
|
||||
|
||||
return new MemberExportDto
|
||||
{
|
||||
FileName = BuildFileName(),
|
||||
FileContentBase64 = Convert.ToBase64String(payload),
|
||||
TotalCount = rows.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFileName()
|
||||
{
|
||||
return $"member-list-{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员日配置查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMemberDaySettingQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMemberDaySettingQuery, MemberDaySettingDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberDaySettingDto> Handle(GetMemberDaySettingQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
return new MemberDaySettingDto
|
||||
{
|
||||
IsEnabled = true,
|
||||
Weekday = 2,
|
||||
ExtraDiscountRate = 9m
|
||||
};
|
||||
}
|
||||
|
||||
return MemberCenterSupport.ToMemberDaySettingDto(setting);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMemberDetailQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMemberDetailQuery, MemberDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberDetailDto?> Handle(
|
||||
GetMemberDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = context.Aggregates.FirstOrDefault(item => item.MemberId == request.MemberId);
|
||||
if (aggregate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tags = await memberRepository.GetProfileTagsAsync(tenantId, aggregate.MemberId, cancellationToken);
|
||||
|
||||
var recentOrders = aggregate.Metrics.Orders
|
||||
.OrderByDescending(item => item.OrderedAt)
|
||||
.ThenByDescending(item => item.OrderId)
|
||||
.Take(3)
|
||||
.Select(item => new MemberRecentOrderDto
|
||||
{
|
||||
OrderedAt = item.OrderedAt,
|
||||
OrderNo = item.OrderNo,
|
||||
Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero),
|
||||
StatusText = MemberCenterSupport.ResolveOrderStatusText(item.Status)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var averageAmount = aggregate.Metrics.TotalOrderCount <= 0
|
||||
? 0
|
||||
: decimal.Round(aggregate.Metrics.TotalAmount / aggregate.Metrics.TotalOrderCount, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new MemberDetailDto
|
||||
{
|
||||
MemberId = aggregate.MemberId,
|
||||
Name = aggregate.Name,
|
||||
AvatarText = aggregate.AvatarText,
|
||||
AvatarColor = aggregate.AvatarColor,
|
||||
MobileMasked = aggregate.MobileMasked,
|
||||
JoinedAt = aggregate.JoinedAt,
|
||||
TierId = aggregate.Tier?.Id,
|
||||
TierName = aggregate.Tier?.Name ?? string.Empty,
|
||||
TierColorHex = aggregate.Tier?.ColorHex ?? "#999999",
|
||||
TotalAmount = decimal.Round(aggregate.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||
OrderCount = aggregate.Metrics.TotalOrderCount,
|
||||
AverageAmount = averageAmount,
|
||||
StoredBalance = aggregate.Profile.StoredBalance,
|
||||
StoredRechargeBalance = aggregate.Profile.StoredRechargeBalance,
|
||||
StoredGiftBalance = aggregate.Profile.StoredGiftBalance,
|
||||
PointsBalance = Math.Max(0, aggregate.Profile.PointsBalance),
|
||||
Tags = tags.Select(item => item.TagName).ToList(),
|
||||
RecentOrders = recentOrders
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表统计查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMemberListStatsQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMemberListStatsQuery, MemberListStatsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberListStatsDto> Handle(
|
||||
GetMemberListStatsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new MemberListStatsDto();
|
||||
}
|
||||
|
||||
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = MemberCenterSupport.ApplyFilters(
|
||||
context.Aggregates,
|
||||
request.Keyword,
|
||||
request.TierId);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
return new MemberListStatsDto
|
||||
{
|
||||
TotalMembers = filtered.Count,
|
||||
MonthlyNewMembers = filtered.Count(item => item.JoinedAt >= monthStart),
|
||||
ActiveMembers = filtered.Count(item => item.Metrics.LastOrderAt >= nowUtc.AddDays(-30)),
|
||||
DormantMembers = filtered.Count(item => item.Metrics.LastOrderAt < nowUtc.AddDays(-60))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMemberTierDetailQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMemberTierDetailQuery, MemberTierDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberTierDetailDto> Handle(
|
||||
GetMemberTierDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
|
||||
if (!request.TierId.HasValue)
|
||||
{
|
||||
var nextSort = tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1;
|
||||
return new MemberTierDetailDto
|
||||
{
|
||||
TierId = null,
|
||||
SortOrder = nextSort,
|
||||
Name = string.Empty,
|
||||
IconKey = "user",
|
||||
ColorHex = "#999999",
|
||||
IsDefault = false,
|
||||
Rule = new MemberTierRuleDto
|
||||
{
|
||||
UpgradeRuleType = "amount",
|
||||
UpgradeAmountThreshold = 500m,
|
||||
UpgradeOrderCountThreshold = null,
|
||||
DowngradeWindowDays = 90
|
||||
},
|
||||
Benefits = new MemberTierBenefitsDto(),
|
||||
CanDelete = false
|
||||
};
|
||||
}
|
||||
|
||||
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value);
|
||||
if (tier is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||
}
|
||||
|
||||
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||
var assignedCount = profiles.Count(item => item.MemberTierId == tier.Id);
|
||||
var canDelete = !tier.IsDefault && assignedCount <= 0 && tiers.Count > 1;
|
||||
|
||||
return MemberCenterSupport.ToTierDetailDto(tier, canDelete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMemberTierListQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMemberTierListQuery, IReadOnlyList<MemberTierListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberTierListItemDto>> Handle(
|
||||
GetMemberTierListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||
|
||||
if (tiers.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var memberCountMap = profiles
|
||||
.Where(item => item.MemberTierId.HasValue)
|
||||
.GroupBy(item => item.MemberTierId!.Value)
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
|
||||
var result = tiers
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Id)
|
||||
.Select(tier =>
|
||||
{
|
||||
memberCountMap.TryGetValue(tier.Id, out var memberCount);
|
||||
var benefits = MemberCenterSupport.DeserializeBenefits(tier.BenefitsJson);
|
||||
var canDelete = !tier.IsDefault && memberCount <= 0 && tiers.Count > 1;
|
||||
|
||||
return new MemberTierListItemDto
|
||||
{
|
||||
TierId = tier.Id,
|
||||
SortOrder = tier.SortOrder,
|
||||
Name = tier.Name,
|
||||
IconKey = tier.IconKey,
|
||||
ColorHex = tier.ColorHex,
|
||||
ConditionText = MemberCenterSupport.BuildConditionText(tier),
|
||||
Perks = MemberCenterSupport.BuildPerks(tier, benefits),
|
||||
MemberCount = memberCount,
|
||||
IsDefault = tier.IsDefault,
|
||||
CanDelete = canDelete
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员日配置命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberDaySettingCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveMemberDaySettingCommand, MemberDaySettingDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberDaySettingDto> Handle(
|
||||
SaveMemberDaySettingCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Weekday is < 1 or > 7)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "weekday 必须在 1-7 之间");
|
||||
}
|
||||
|
||||
var normalizedDiscount = decimal.Round(request.ExtraDiscountRate, 2, MidpointRounding.AwayFromZero);
|
||||
if (normalizedDiscount <= 0 || normalizedDiscount > 10)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "extraDiscountRate 必须大于 0 且不超过 10");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new MemberDaySetting
|
||||
{
|
||||
IsEnabled = request.IsEnabled,
|
||||
Weekday = request.Weekday,
|
||||
ExtraDiscountRate = normalizedDiscount
|
||||
};
|
||||
await memberRepository.AddMemberDaySettingAsync(setting, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.IsEnabled = request.IsEnabled;
|
||||
setting.Weekday = request.Weekday;
|
||||
setting.ExtraDiscountRate = normalizedDiscount;
|
||||
await memberRepository.UpdateMemberDaySettingAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||
return MemberCenterSupport.ToMemberDaySettingDto(setting);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员标签命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTagsCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveMemberTagsCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Handle(SaveMemberTagsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var profile = await memberRepository.FindProfileByIdAsync(tenantId, request.MemberId, cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
|
||||
}
|
||||
|
||||
var tags = (request.Tags ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (tags.Count > 20)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "标签数量不能超过 20 个");
|
||||
}
|
||||
|
||||
if (tags.Any(item => item.Length > 32))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "标签长度不能超过 32 个字符");
|
||||
}
|
||||
|
||||
await memberRepository.ReplaceProfileTagsAsync(tenantId, request.MemberId, tags, cancellationToken);
|
||||
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员等级命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTierCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveMemberTierCommand, MemberTierDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberTierDetailDto> Handle(
|
||||
SaveMemberTierCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
cancellationToken);
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
var normalizedName = (request.Name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能为空");
|
||||
}
|
||||
|
||||
if (normalizedName.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能超过 64 个字符");
|
||||
}
|
||||
|
||||
var normalizedIconKey = (request.IconKey ?? "user").Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalizedIconKey) || normalizedIconKey.Length > 32)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "等级图标不合法");
|
||||
}
|
||||
|
||||
var normalizedColorHex = NormalizeColorHex(request.ColorHex);
|
||||
var normalizedRuleType = MemberCenterSupport.NormalizeRuleType(request.Rule.UpgradeRuleType);
|
||||
var amountThreshold = request.Rule.UpgradeAmountThreshold;
|
||||
var countThreshold = request.Rule.UpgradeOrderCountThreshold;
|
||||
|
||||
if (normalizedRuleType is "amount" or "both")
|
||||
{
|
||||
if (!amountThreshold.HasValue || amountThreshold.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "升级金额门槛必须大于 0");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedRuleType is "count" or "both")
|
||||
{
|
||||
if (!countThreshold.HasValue || countThreshold.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "升级消费次数门槛必须大于 0");
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedDowngradeWindowDays = Math.Clamp(request.Rule.DowngradeWindowDays <= 0 ? 90 : request.Rule.DowngradeWindowDays, 30, 365);
|
||||
var normalizedSortOrder = request.SortOrder > 0
|
||||
? request.SortOrder
|
||||
: (request.TierId.HasValue
|
||||
? tiers.FirstOrDefault(item => item.Id == request.TierId.Value)?.SortOrder ?? 1
|
||||
: (tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1));
|
||||
|
||||
var normalizedBenefits = MemberCenterSupport.NormalizeBenefits(request.Benefits);
|
||||
var duplicate = tiers.Any(item =>
|
||||
item.Id != (request.TierId ?? 0) &&
|
||||
string.Equals(item.Name, normalizedName, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "等级名称已存在");
|
||||
}
|
||||
|
||||
MemberTier targetTier;
|
||||
if (request.TierId.HasValue)
|
||||
{
|
||||
targetTier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||
targetTier.Name = normalizedName;
|
||||
targetTier.IconKey = normalizedIconKey;
|
||||
targetTier.ColorHex = normalizedColorHex;
|
||||
targetTier.UpgradeRuleType = normalizedRuleType;
|
||||
targetTier.UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
|
||||
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
targetTier.UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
|
||||
? Math.Max(0, countThreshold ?? 0)
|
||||
: null;
|
||||
targetTier.DowngradeWindowDays = normalizedDowngradeWindowDays;
|
||||
targetTier.IsDefault = request.IsDefault;
|
||||
targetTier.SortOrder = normalizedSortOrder;
|
||||
targetTier.RequiredGrowth = ResolveRequiredGrowth(targetTier.UpgradeAmountThreshold, targetTier.UpgradeOrderCountThreshold);
|
||||
targetTier.BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits);
|
||||
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetTier = new MemberTier
|
||||
{
|
||||
Name = normalizedName,
|
||||
IconKey = normalizedIconKey,
|
||||
ColorHex = normalizedColorHex,
|
||||
UpgradeRuleType = normalizedRuleType,
|
||||
UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
|
||||
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
|
||||
: null,
|
||||
UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
|
||||
? Math.Max(0, countThreshold ?? 0)
|
||||
: null,
|
||||
DowngradeWindowDays = normalizedDowngradeWindowDays,
|
||||
IsDefault = request.IsDefault,
|
||||
SortOrder = normalizedSortOrder,
|
||||
RequiredGrowth = ResolveRequiredGrowth(amountThreshold, countThreshold),
|
||||
BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits)
|
||||
};
|
||||
await memberRepository.AddTierAsync(targetTier, cancellationToken);
|
||||
tiers = tiers.Append(targetTier).ToList();
|
||||
}
|
||||
|
||||
if (request.IsDefault)
|
||||
{
|
||||
foreach (var tier in tiers.Where(item => item.Id != targetTier.Id && item.IsDefault))
|
||||
{
|
||||
tier.IsDefault = false;
|
||||
await memberRepository.UpdateTierAsync(tier, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var hasDefault = tiers.Any(item => (item.Id == targetTier.Id ? targetTier.IsDefault : item.IsDefault));
|
||||
if (!hasDefault)
|
||||
{
|
||||
var fallbackTier = tiers
|
||||
.Where(item => item.Id != targetTier.Id)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Id)
|
||||
.FirstOrDefault();
|
||||
if (fallbackTier is not null)
|
||||
{
|
||||
fallbackTier.IsDefault = true;
|
||||
await memberRepository.UpdateTierAsync(fallbackTier, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetTier.IsDefault = true;
|
||||
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var profileCount = (await memberRepository.GetProfilesAsync(tenantId, cancellationToken)).Count(item => item.MemberTierId == targetTier.Id);
|
||||
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||
var canDelete = !targetTier.IsDefault && profileCount <= 0 && latestTiers.Count > 1;
|
||||
var latestTier = latestTiers.First(item => item.Id == targetTier.Id);
|
||||
return MemberCenterSupport.ToTierDetailDto(latestTier, canDelete);
|
||||
}
|
||||
|
||||
private static string NormalizeColorHex(string? value)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return "#999999";
|
||||
}
|
||||
|
||||
if (!candidate.StartsWith('#'))
|
||||
{
|
||||
candidate = $"#{candidate}";
|
||||
}
|
||||
|
||||
if (candidate.Length is not (4 or 7 or 9))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "等级颜色不合法");
|
||||
}
|
||||
|
||||
return candidate.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int ResolveRequiredGrowth(decimal? amountThreshold, int? countThreshold)
|
||||
{
|
||||
var amountGrowth = amountThreshold.HasValue ? (int)Math.Clamp(decimal.Round(amountThreshold.Value, 0, MidpointRounding.AwayFromZero), 0m, int.MaxValue) : 0;
|
||||
var countGrowth = Math.Max(0, countThreshold ?? 0) * 100;
|
||||
return Math.Max(amountGrowth, countGrowth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券选择器查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberCouponPickerQueryHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchMemberCouponPickerQuery, IReadOnlyList<MemberCouponPickerItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberCouponPickerItemDto>> Handle(
|
||||
SearchMemberCouponPickerQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var keyword = (request.Keyword ?? string.Empty).Trim();
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
|
||||
|
||||
var filtered = templates
|
||||
.Where(template => MemberCenterSupport.IsCouponVisibleToStores(template, request.VisibleStoreIds))
|
||||
.Where(template => MemberCenterSupport.IsCouponActive(template, nowUtc))
|
||||
.Where(template =>
|
||||
string.IsNullOrWhiteSpace(keyword) ||
|
||||
template.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(template => template.UpdatedAt ?? template.CreatedAt)
|
||||
.ThenByDescending(template => template.Id)
|
||||
.Take(200)
|
||||
.Select(template => new MemberCouponPickerItemDto
|
||||
{
|
||||
CouponTemplateId = template.Id,
|
||||
Name = template.Name,
|
||||
CouponType = MemberCenterSupport.ResolveCouponTypeText(template.CouponType),
|
||||
Value = decimal.Round(template.Value, 2, MidpointRounding.AwayFromZero),
|
||||
MinimumSpend = template.MinimumSpend,
|
||||
DisplayText = MemberCenterSupport.BuildCouponDisplayText(template)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberListQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchMemberListQuery, PagedResult<MemberListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<MemberListItemDto>> Handle(
|
||||
SearchMemberListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new PagedResult<MemberListItemDto>([], page, pageSize, 0);
|
||||
}
|
||||
|
||||
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||
memberRepository,
|
||||
orderRepository,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = MemberCenterSupport.ApplyFilters(
|
||||
context.Aggregates,
|
||||
request.Keyword,
|
||||
request.TierId)
|
||||
.OrderByDescending(item => item.Metrics.LastOrderAt)
|
||||
.ThenByDescending(item => item.MemberId)
|
||||
.ToList();
|
||||
|
||||
var totalCount = filtered.Count;
|
||||
var items = filtered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(item => new MemberListItemDto
|
||||
{
|
||||
MemberId = item.MemberId,
|
||||
Name = item.Name,
|
||||
AvatarText = item.AvatarText,
|
||||
AvatarColor = item.AvatarColor,
|
||||
MobileMasked = item.MobileMasked,
|
||||
TierId = item.Tier?.Id,
|
||||
TierName = item.Tier?.Name ?? string.Empty,
|
||||
TierColorHex = item.Tier?.ColorHex ?? "#999999",
|
||||
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||
OrderCount = item.Metrics.TotalOrderCount,
|
||||
LastOrderAt = item.Metrics.LastOrderAt,
|
||||
StoredBalance = item.Profile.StoredBalance,
|
||||
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
|
||||
IsDormant = item.IsDormant
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<MemberListItemDto>(items, page, pageSize, totalCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表 CSV 导出查询。
|
||||
/// </summary>
|
||||
public sealed class ExportMemberCsvQuery : IRequest<MemberExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员日配置查询。
|
||||
/// </summary>
|
||||
public sealed class GetMemberDaySettingQuery : IRequest<MemberDaySettingDto>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetMemberDetailQuery : IRequest<MemberDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表统计查询。
|
||||
/// </summary>
|
||||
public sealed class GetMemberListStatsQuery : IRequest<MemberListStatsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetMemberTierDetailQuery : IRequest<MemberTierDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识(为空时返回新增默认模板)。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级列表查询。
|
||||
/// </summary>
|
||||
public sealed class GetMemberTierListQuery : IRequest<IReadOnlyList<MemberTierListItemDto>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券选择器查询。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberCouponPickerQuery : IRequest<IReadOnlyList<MemberCouponPickerItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表查询。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberListQuery : IRequest<PagedResult<MemberListItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public long? TierId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
Reference in New Issue
Block a user