From d96ca4971a9804ceb372975dfe57f422b869b4ff Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 3 Mar 2026 20:38:31 +0800 Subject: [PATCH] feat(member): implement member center management module --- TakeoutSaaS.Docs | 2 +- .../Contracts/Member/MemberListContracts.cs | 346 +++++ .../Contracts/Member/MemberTierContracts.cs | 427 +++++++ .../Controllers/MemberController.cs | 251 ++++ .../Controllers/MemberTierController.cs | 330 +++++ .../Commands/DeleteMemberTierCommand.cs | 14 + .../Commands/SaveMemberDaySettingCommand.cs | 25 + .../Members/Commands/SaveMemberTagsCommand.cs | 19 + .../Members/Commands/SaveMemberTierCommand.cs | 50 + .../App/Members/Dto/MemberDtos.cs | 567 +++++++++ .../DeleteMemberTierCommandHandler.cs | 60 + .../Handlers/ExportMemberCsvQueryHandler.cs | 87 ++ .../GetMemberDaySettingQueryHandler.cs | 42 + .../Handlers/GetMemberDetailQueryHandler.cs | 84 ++ .../GetMemberListStatsQueryHandler.cs | 52 + .../GetMemberTierDetailQueryHandler.cs | 70 ++ .../Handlers/GetMemberTierListQueryHandler.cs | 71 ++ .../Members/Handlers/MemberCenterSupport.cs | 1108 +++++++++++++++++ .../SaveMemberDaySettingCommandHandler.cs | 67 + .../Handlers/SaveMemberTagsCommandHandler.cs | 47 + .../Handlers/SaveMemberTierCommandHandler.cs | 202 +++ .../SearchMemberCouponPickerQueryHandler.cs | 55 + .../Handlers/SearchMemberListQueryHandler.cs | 73 ++ .../Members/Queries/ExportMemberCsvQuery.cs | 25 + .../Queries/GetMemberDaySettingQuery.cs | 11 + .../Members/Queries/GetMemberDetailQuery.cs | 20 + .../Queries/GetMemberListStatsQuery.cs | 25 + .../Queries/GetMemberTierDetailQuery.cs | 15 + .../Members/Queries/GetMemberTierListQuery.cs | 11 + .../Queries/SearchMemberCouponPickerQuery.cs | 20 + .../Members/Queries/SearchMemberListQuery.cs | 36 + .../Membership/Entities/MemberDaySetting.cs | 24 + .../Membership/Entities/MemberProfile.cs | 15 + .../Membership/Entities/MemberProfileTag.cs | 19 + .../Membership/Entities/MemberTier.cs | 35 + .../Repositories/IMemberRepository.cs | 99 ++ .../AppServiceCollectionExtensions.cs | 2 + .../App/Persistence/TakeoutAppDbContext.cs | 39 + .../App/Repositories/EfMemberRepository.cs | 177 +++ .../20260303103000_AddMemberCenterModule.cs | 225 ++++ 40 files changed, 4846 insertions(+), 1 deletion(-) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberListContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberTierContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MemberTierController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Commands/DeleteMemberTierCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberDaySettingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTagsCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTierCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Dto/MemberDtos.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/DeleteMemberTierCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/ExportMemberCsvQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDaySettingQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberListStatsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/MemberCenterSupport.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberDaySettingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTagsCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTierCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberCouponPickerQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/ExportMemberCsvQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDaySettingQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberListStatsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberCouponPickerQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberListQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberDaySetting.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfileTag.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260303103000_AddMemberCenterModule.cs diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs index d7d55e9..9006c8a 160000 --- a/TakeoutSaaS.Docs +++ b/TakeoutSaaS.Docs @@ -1 +1 @@ -Subproject commit d7d55e990836f82ef7de91af02ae2f3a3a02b71b +Subproject commit 9006c8a58995d4adddab28599193c3631935ee43 diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberListContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberListContracts.cs new file mode 100644 index 0000000..f21cc4e --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberListContracts.cs @@ -0,0 +1,346 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Member; + +/// +/// 会员列表筛选请求。 +/// +public class MemberListFilterRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; set; } + + /// + /// 等级标识。 + /// + public string? TierId { get; set; } +} + +/// +/// 会员列表分页请求。 +/// +public sealed class MemberListRequest : MemberListFilterRequest +{ + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 10; +} + +/// +/// 会员详情请求。 +/// +public sealed class MemberDetailRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 会员标识。 + /// + public string MemberId { get; set; } = string.Empty; +} + +/// +/// 保存会员标签请求。 +/// +public sealed class SaveMemberTagsRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 会员标识。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 标签集合。 + /// + public List Tags { get; set; } = []; +} + +/// +/// 会员列表行响应。 +/// +public sealed class MemberListItemResponse +{ + /// + /// 会员标识。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 会员名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; set; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; set; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string MobileMasked { get; set; } = string.Empty; + + /// + /// 会员等级标识。 + /// + public string? TierId { get; set; } + + /// + /// 会员等级名称。 + /// + public string TierName { get; set; } = string.Empty; + + /// + /// 等级主题色。 + /// + public string TierColorHex { get; set; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 消费次数。 + /// + public int OrderCount { get; set; } + + /// + /// 最近消费时间(yyyy-MM-dd)。 + /// + public string LastOrderAt { get; set; } = string.Empty; + + /// + /// 储值余额。 + /// + public decimal StoredBalance { get; set; } + + /// + /// 积分余额。 + /// + public int PointsBalance { get; set; } + + /// + /// 是否沉睡会员。 + /// + public bool IsDormant { get; set; } +} + +/// +/// 会员列表响应。 +/// +public sealed class MemberListResultResponse +{ + /// + /// 列表项。 + /// + public List Items { get; set; } = []; + + /// + /// 总数。 + /// + public int Total { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } +} + +/// +/// 会员列表统计响应。 +/// +public sealed class MemberListStatsResponse +{ + /// + /// 会员总数。 + /// + public int TotalMembers { get; set; } + + /// + /// 本月新增会员数。 + /// + public int MonthlyNewMembers { get; set; } + + /// + /// 活跃会员数。 + /// + public int ActiveMembers { get; set; } + + /// + /// 沉睡会员数。 + /// + public int DormantMembers { get; set; } +} + +/// +/// 会员最近订单响应。 +/// +public sealed class MemberRecentOrderResponse +{ + /// + /// 下单日期(yyyy-MM-dd)。 + /// + public string OrderedAt { get; set; } = string.Empty; + + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 订单金额。 + /// + public decimal Amount { get; set; } + + /// + /// 订单状态文案。 + /// + public string StatusText { get; set; } = string.Empty; +} + +/// +/// 会员详情响应。 +/// +public sealed class MemberDetailResponse +{ + /// + /// 会员标识。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 会员名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; set; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; set; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string MobileMasked { get; set; } = string.Empty; + + /// + /// 注册时间(yyyy-MM-dd)。 + /// + public string JoinedAt { get; set; } = string.Empty; + + /// + /// 会员等级标识。 + /// + public string? TierId { get; set; } + + /// + /// 会员等级名称。 + /// + public string TierName { get; set; } = string.Empty; + + /// + /// 等级主题色。 + /// + public string TierColorHex { get; set; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 消费次数。 + /// + public int OrderCount { get; set; } + + /// + /// 平均客单价。 + /// + public decimal AverageAmount { get; set; } + + /// + /// 储值余额。 + /// + public decimal StoredBalance { get; set; } + + /// + /// 储值实充余额。 + /// + public decimal StoredRechargeBalance { get; set; } + + /// + /// 储值赠金余额。 + /// + public decimal StoredGiftBalance { get; set; } + + /// + /// 积分余额。 + /// + public int PointsBalance { get; set; } + + /// + /// 会员标签。 + /// + public List Tags { get; set; } = []; + + /// + /// 最近订单。 + /// + public List RecentOrders { get; set; } = []; +} + +/// +/// 会员导出响应。 +/// +public sealed class MemberExportResponse +{ + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件内容 Base64。 + /// + public string FileContentBase64 { get; set; } = string.Empty; + + /// + /// 导出总数。 + /// + public int TotalCount { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberTierContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberTierContracts.cs new file mode 100644 index 0000000..e8698ef --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberTierContracts.cs @@ -0,0 +1,427 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Member; + +/// +/// 会员等级列表项响应。 +/// +public sealed class MemberTierListItemResponse +{ + /// + /// 等级标识。 + /// + public string TierId { get; set; } = string.Empty; + + /// + /// 排序序号。 + /// + public int SortOrder { get; set; } + + /// + /// 等级名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; set; } = string.Empty; + + /// + /// 主题色。 + /// + public string ColorHex { get; set; } = string.Empty; + + /// + /// 升级条件文案。 + /// + public string ConditionText { get; set; } = string.Empty; + + /// + /// 权益摘要。 + /// + public List Perks { get; set; } = []; + + /// + /// 等级会员数。 + /// + public int MemberCount { get; set; } + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否可删除。 + /// + public bool CanDelete { get; set; } +} + +/// +/// 等级详情查询请求。 +/// +public sealed class MemberTierDetailRequest +{ + /// + /// 等级标识。 + /// + public string? TierId { get; set; } +} + +/// +/// 等级规则响应。 +/// +public sealed class MemberTierRuleResponse +{ + /// + /// 升级规则类型。 + /// + public string UpgradeRuleType { get; set; } = "none"; + + /// + /// 升级累计消费门槛。 + /// + public decimal? UpgradeAmountThreshold { get; set; } + + /// + /// 升级消费次数门槛。 + /// + public int? UpgradeOrderCountThreshold { get; set; } + + /// + /// 降级观察窗口天数。 + /// + public int DowngradeWindowDays { get; set; } +} + +/// +/// 折扣权益响应。 +/// +public sealed class MemberTierDiscountBenefitResponse +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } + + /// + /// 折扣值。 + /// + public decimal? DiscountRate { get; set; } +} + +/// +/// 积分倍率权益响应。 +/// +public sealed class MemberTierPointMultiplierBenefitResponse +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } + + /// + /// 倍率。 + /// + public decimal? Multiplier { get; set; } +} + +/// +/// 生日特权响应。 +/// +public sealed class MemberTierBirthdayBenefitResponse +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } + + /// + /// 是否双倍积分。 + /// + public bool DoublePointsEnabled { get; set; } + + /// + /// 券模板 ID。 + /// + public List CouponTemplateIds { get; set; } = []; +} + +/// +/// 每月赠券响应。 +/// +public sealed class MemberTierMonthlyCouponBenefitResponse +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } + + /// + /// 每月发放日。 + /// + public int GrantDay { get; set; } + + /// + /// 券模板 ID。 + /// + public List CouponTemplateIds { get; set; } = []; +} + +/// +/// 免配送费权益响应。 +/// +public sealed class MemberTierFreeDeliveryBenefitResponse +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } + + /// + /// 每月免配送费次数。 + /// + public int MonthlyFreeTimes { get; set; } +} + +/// +/// 等级权益响应。 +/// +public sealed class MemberTierBenefitsResponse +{ + /// + /// 折扣权益。 + /// + public MemberTierDiscountBenefitResponse Discount { get; set; } = new(); + + /// + /// 积分倍率权益。 + /// + public MemberTierPointMultiplierBenefitResponse PointMultiplier { get; set; } = new(); + + /// + /// 生日特权。 + /// + public MemberTierBirthdayBenefitResponse Birthday { get; set; } = new(); + + /// + /// 每月赠券。 + /// + public MemberTierMonthlyCouponBenefitResponse MonthlyCoupon { get; set; } = new(); + + /// + /// 免配送费权益。 + /// + public MemberTierFreeDeliveryBenefitResponse FreeDelivery { get; set; } = new(); + + /// + /// 优先配送。 + /// + public bool PriorityDeliveryEnabled { get; set; } + + /// + /// 专属客服。 + /// + public bool ExclusiveServiceEnabled { get; set; } +} + +/// +/// 等级详情响应。 +/// +public sealed class MemberTierDetailResponse +{ + /// + /// 等级标识。 + /// + public string? TierId { get; set; } + + /// + /// 排序序号。 + /// + public int SortOrder { get; set; } + + /// + /// 等级名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; set; } = string.Empty; + + /// + /// 主题色。 + /// + public string ColorHex { get; set; } = string.Empty; + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; set; } + + /// + /// 升降级规则。 + /// + public MemberTierRuleResponse Rule { get; set; } = new(); + + /// + /// 等级权益。 + /// + public MemberTierBenefitsResponse Benefits { get; set; } = new(); + + /// + /// 是否可删除。 + /// + public bool CanDelete { get; set; } +} + +/// +/// 保存等级请求。 +/// +public sealed class SaveMemberTierRequest +{ + /// + /// 等级标识(为空时新增)。 + /// + public string? TierId { get; set; } + + /// + /// 排序序号。 + /// + public int SortOrder { get; set; } + + /// + /// 等级名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; set; } = "user"; + + /// + /// 主题色。 + /// + public string ColorHex { get; set; } = "#999999"; + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; set; } + + /// + /// 升降级规则。 + /// + public MemberTierRuleResponse Rule { get; set; } = new(); + + /// + /// 等级权益。 + /// + public MemberTierBenefitsResponse Benefits { get; set; } = new(); +} + +/// +/// 删除等级请求。 +/// +public sealed class DeleteMemberTierRequest +{ + /// + /// 等级标识。 + /// + public string TierId { get; set; } = string.Empty; +} + +/// +/// 会员日配置响应。 +/// +public sealed class MemberDaySettingResponse +{ + /// + /// 是否启用会员日。 + /// + public bool IsEnabled { get; set; } + + /// + /// 周几(1-7,对应周一到周日)。 + /// + public int Weekday { get; set; } + + /// + /// 会员日额外折扣。 + /// + public decimal ExtraDiscountRate { get; set; } +} + +/// +/// 保存会员日配置请求。 +/// +public sealed class SaveMemberDaySettingRequest +{ + /// + /// 是否启用会员日。 + /// + public bool IsEnabled { get; set; } + + /// + /// 周几(1-7,对应周一到周日)。 + /// + public int Weekday { get; set; } + + /// + /// 会员日额外折扣。 + /// + public decimal ExtraDiscountRate { get; set; } +} + +/// +/// 优惠券选择器请求。 +/// +public sealed class MemberCouponPickerRequest +{ + /// + /// 门店 ID(可选)。 + /// + public string? StoreId { get; set; } + + /// + /// 关键词。 + /// + public string? Keyword { get; set; } +} + +/// +/// 优惠券选择器项响应。 +/// +public sealed class MemberCouponPickerItemResponse +{ + /// + /// 券模板标识。 + /// + public string CouponTemplateId { get; set; } = string.Empty; + + /// + /// 券名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 券类型。 + /// + public string CouponType { get; set; } = string.Empty; + + /// + /// 面值或折扣值。 + /// + public decimal Value { get; set; } + + /// + /// 最低消费门槛。 + /// + public decimal? MinimumSpend { get; set; } + + /// + /// 展示文案。 + /// + public string DisplayText { get; set; } = string.Empty; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs new file mode 100644 index 0000000..23184ed --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Members.Commands; +using TakeoutSaaS.Application.App.Members.Dto; +using TakeoutSaaS.Application.App.Members.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Member; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 会员管理列表与详情。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/member/list")] +public sealed class MemberController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) + : BaseApiController +{ + private const string ViewPermission = "tenant:member:view"; + private const string ManagePermission = "tenant:member:manage"; + + /// + /// 获取会员列表。 + /// + [HttpGet("list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] MemberListRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new SearchMemberListQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword, + TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId), + Page = Math.Max(1, request.Page), + PageSize = Math.Clamp(request.PageSize, 1, 200) + }, cancellationToken); + + return ApiResponse.Ok(new MemberListResultResponse + { + Items = result.Items.Select(MapListItem).ToList(), + Total = result.TotalCount, + Page = result.Page, + PageSize = result.PageSize + }); + } + + /// + /// 获取会员列表统计。 + /// + [HttpGet("stats")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Stats( + [FromQuery] MemberListFilterRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new GetMemberListStatsQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword, + TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId) + }, cancellationToken); + + return ApiResponse.Ok(new MemberListStatsResponse + { + TotalMembers = result.TotalMembers, + MonthlyNewMembers = result.MonthlyNewMembers, + ActiveMembers = result.ActiveMembers, + DormantMembers = result.DormantMembers + }); + } + + /// + /// 获取会员详情。 + /// + [HttpGet("detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] MemberDetailRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new GetMemberDetailQuery + { + VisibleStoreIds = visibleStoreIds, + MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)) + }, cancellationToken); + + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "会员不存在"); + } + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 保存会员标签。 + /// + [HttpPost("tags")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaveTags( + [FromBody] SaveMemberTagsRequest request, + CancellationToken cancellationToken) + { + _ = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + await mediator.Send(new SaveMemberTagsCommand + { + MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)), + Tags = (request.Tags ?? []).ToList() + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 导出会员列表 CSV。 + /// + [HttpGet("export")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Export( + [FromQuery] MemberListFilterRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new ExportMemberCsvQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword, + TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId) + }, cancellationToken); + + return ApiResponse.Ok(new MemberExportResponse + { + FileName = result.FileName, + FileContentBase64 = result.FileContentBase64, + TotalCount = result.TotalCount + }); + } + + private async Task> ResolveVisibleStoreIdsAsync( + string? storeId, + CancellationToken cancellationToken) + { + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + + if (!string.IsNullOrWhiteSpace(storeId)) + { + var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId)); + await StoreApiHelpers.EnsureStoreAccessibleAsync( + dbContext, + tenantId, + merchantId, + parsedStoreId, + cancellationToken); + return [parsedStoreId]; + } + + var allStoreIds = await dbContext.Stores + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.MerchantId == merchantId) + .Select(item => item.Id) + .OrderBy(item => item) + .ToListAsync(cancellationToken); + + if (allStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店"); + } + + return allStoreIds; + } + + private static MemberListItemResponse MapListItem(MemberListItemDto source) + { + return new MemberListItemResponse + { + MemberId = source.MemberId.ToString(), + Name = source.Name, + AvatarText = source.AvatarText, + AvatarColor = source.AvatarColor, + MobileMasked = source.MobileMasked, + TierId = source.TierId?.ToString(), + TierName = source.TierName, + TierColorHex = source.TierColorHex, + TotalAmount = source.TotalAmount, + OrderCount = source.OrderCount, + LastOrderAt = source.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + StoredBalance = source.StoredBalance, + PointsBalance = source.PointsBalance, + IsDormant = source.IsDormant + }; + } + + private static MemberDetailResponse MapDetail(MemberDetailDto source) + { + return new MemberDetailResponse + { + MemberId = source.MemberId.ToString(), + Name = source.Name, + AvatarText = source.AvatarText, + AvatarColor = source.AvatarColor, + MobileMasked = source.MobileMasked, + JoinedAt = source.JoinedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + TierId = source.TierId?.ToString(), + TierName = source.TierName, + TierColorHex = source.TierColorHex, + TotalAmount = source.TotalAmount, + OrderCount = source.OrderCount, + AverageAmount = source.AverageAmount, + StoredBalance = source.StoredBalance, + StoredRechargeBalance = source.StoredRechargeBalance, + StoredGiftBalance = source.StoredGiftBalance, + PointsBalance = source.PointsBalance, + Tags = source.Tags.ToList(), + RecentOrders = source.RecentOrders.Select(item => new MemberRecentOrderResponse + { + OrderedAt = item.OrderedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + OrderNo = item.OrderNo, + Amount = item.Amount, + StatusText = item.StatusText + }).ToList() + }; + } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberTierController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberTierController.cs new file mode 100644 index 0000000..56c9405 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberTierController.cs @@ -0,0 +1,330 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Members.Commands; +using TakeoutSaaS.Application.App.Members.Dto; +using TakeoutSaaS.Application.App.Members.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Member; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 会员等级体系与会员日配置。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/member/tier")] +public sealed class MemberTierController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) + : BaseApiController +{ + private const string ViewPermission = "tenant:member:view"; + private const string ManagePermission = "tenant:member:manage"; + + /// + /// 获取会员等级列表。 + /// + [HttpGet("list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMemberTierListQuery(), cancellationToken); + return ApiResponse>.Ok(result.Select(MapTierListItem).ToList()); + } + + /// + /// 获取会员等级详情。 + /// + [HttpGet("detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] MemberTierDetailRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMemberTierDetailQuery + { + TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId) + }, cancellationToken); + + return ApiResponse.Ok(MapTierDetail(result)); + } + + /// + /// 保存会员等级。 + /// + [HttpPost("save")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Save( + [FromBody] SaveMemberTierRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SaveMemberTierCommand + { + TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId), + SortOrder = request.SortOrder, + Name = request.Name, + IconKey = request.IconKey, + ColorHex = request.ColorHex, + IsDefault = request.IsDefault, + Rule = new MemberTierRuleDto + { + UpgradeRuleType = request.Rule.UpgradeRuleType, + UpgradeAmountThreshold = request.Rule.UpgradeAmountThreshold, + UpgradeOrderCountThreshold = request.Rule.UpgradeOrderCountThreshold, + DowngradeWindowDays = request.Rule.DowngradeWindowDays + }, + Benefits = MapBenefits(request.Benefits) + }, cancellationToken); + + return ApiResponse.Ok(MapTierDetail(result)); + } + + /// + /// 删除会员等级。 + /// + [HttpPost("delete")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete( + [FromBody] DeleteMemberTierRequest request, + CancellationToken cancellationToken) + { + await mediator.Send(new DeleteMemberTierCommand + { + TierId = StoreApiHelpers.ParseRequiredSnowflake(request.TierId, nameof(request.TierId)) + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 获取会员日配置。 + /// + [HttpGet("day-setting")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetDaySetting(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMemberDaySettingQuery(), cancellationToken); + return ApiResponse.Ok(new MemberDaySettingResponse + { + IsEnabled = result.IsEnabled, + Weekday = result.Weekday, + ExtraDiscountRate = result.ExtraDiscountRate + }); + } + + /// + /// 保存会员日配置。 + /// + [HttpPost("day-setting")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaveDaySetting( + [FromBody] SaveMemberDaySettingRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SaveMemberDaySettingCommand + { + IsEnabled = request.IsEnabled, + Weekday = request.Weekday, + ExtraDiscountRate = request.ExtraDiscountRate + }, cancellationToken); + + return ApiResponse.Ok(new MemberDaySettingResponse + { + IsEnabled = result.IsEnabled, + Weekday = result.Weekday, + ExtraDiscountRate = result.ExtraDiscountRate + }); + } + + /// + /// 查询可选优惠券列表。 + /// + [HttpGet("coupon-picker")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> CouponPicker( + [FromQuery] MemberCouponPickerRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new SearchMemberCouponPickerQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword + }, cancellationToken); + + return ApiResponse>.Ok(result.Select(item => new MemberCouponPickerItemResponse + { + CouponTemplateId = item.CouponTemplateId.ToString(), + Name = item.Name, + CouponType = item.CouponType, + Value = item.Value, + MinimumSpend = item.MinimumSpend, + DisplayText = item.DisplayText + }).ToList()); + } + + private async Task> ResolveVisibleStoreIdsAsync( + string? storeId, + CancellationToken cancellationToken) + { + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + + if (!string.IsNullOrWhiteSpace(storeId)) + { + var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId)); + await StoreApiHelpers.EnsureStoreAccessibleAsync( + dbContext, + tenantId, + merchantId, + parsedStoreId, + cancellationToken); + return [parsedStoreId]; + } + + var allStoreIds = await dbContext.Stores + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.MerchantId == merchantId) + .Select(item => item.Id) + .OrderBy(item => item) + .ToListAsync(cancellationToken); + + if (allStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店"); + } + + return allStoreIds; + } + + private static MemberTierListItemResponse MapTierListItem(MemberTierListItemDto source) + { + return new MemberTierListItemResponse + { + TierId = source.TierId.ToString(), + SortOrder = source.SortOrder, + Name = source.Name, + IconKey = source.IconKey, + ColorHex = source.ColorHex, + ConditionText = source.ConditionText, + Perks = source.Perks.ToList(), + MemberCount = source.MemberCount, + IsDefault = source.IsDefault, + CanDelete = source.CanDelete + }; + } + + private static MemberTierDetailResponse MapTierDetail(MemberTierDetailDto source) + { + return new MemberTierDetailResponse + { + TierId = source.TierId?.ToString(), + SortOrder = source.SortOrder, + Name = source.Name, + IconKey = source.IconKey, + ColorHex = source.ColorHex, + IsDefault = source.IsDefault, + Rule = new MemberTierRuleResponse + { + UpgradeRuleType = source.Rule.UpgradeRuleType, + UpgradeAmountThreshold = source.Rule.UpgradeAmountThreshold, + UpgradeOrderCountThreshold = source.Rule.UpgradeOrderCountThreshold, + DowngradeWindowDays = source.Rule.DowngradeWindowDays + }, + Benefits = new MemberTierBenefitsResponse + { + Discount = new MemberTierDiscountBenefitResponse + { + Enabled = source.Benefits.Discount.Enabled, + DiscountRate = source.Benefits.Discount.DiscountRate + }, + PointMultiplier = new MemberTierPointMultiplierBenefitResponse + { + Enabled = source.Benefits.PointMultiplier.Enabled, + Multiplier = source.Benefits.PointMultiplier.Multiplier + }, + Birthday = new MemberTierBirthdayBenefitResponse + { + Enabled = source.Benefits.Birthday.Enabled, + DoublePointsEnabled = source.Benefits.Birthday.DoublePointsEnabled, + CouponTemplateIds = source.Benefits.Birthday.CouponTemplateIds.Select(item => item.ToString()).ToList() + }, + MonthlyCoupon = new MemberTierMonthlyCouponBenefitResponse + { + Enabled = source.Benefits.MonthlyCoupon.Enabled, + GrantDay = source.Benefits.MonthlyCoupon.GrantDay, + CouponTemplateIds = source.Benefits.MonthlyCoupon.CouponTemplateIds.Select(item => item.ToString()).ToList() + }, + FreeDelivery = new MemberTierFreeDeliveryBenefitResponse + { + Enabled = source.Benefits.FreeDelivery.Enabled, + MonthlyFreeTimes = source.Benefits.FreeDelivery.MonthlyFreeTimes + }, + PriorityDeliveryEnabled = source.Benefits.PriorityDeliveryEnabled, + ExclusiveServiceEnabled = source.Benefits.ExclusiveServiceEnabled + }, + CanDelete = source.CanDelete + }; + } + + private static MemberTierBenefitsDto MapBenefits(MemberTierBenefitsResponse source) + { + return new MemberTierBenefitsDto + { + Discount = new MemberTierDiscountBenefitDto + { + Enabled = source.Discount.Enabled, + DiscountRate = source.Discount.DiscountRate + }, + PointMultiplier = new MemberTierPointMultiplierBenefitDto + { + Enabled = source.PointMultiplier.Enabled, + Multiplier = source.PointMultiplier.Multiplier + }, + Birthday = new MemberTierBirthdayBenefitDto + { + Enabled = source.Birthday.Enabled, + DoublePointsEnabled = source.Birthday.DoublePointsEnabled, + CouponTemplateIds = source.Birthday.CouponTemplateIds + .Select(StoreApiHelpers.ParseSnowflakeOrNull) + .Where(item => item.HasValue) + .Select(item => item!.Value) + .ToList() + }, + MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto + { + Enabled = source.MonthlyCoupon.Enabled, + GrantDay = source.MonthlyCoupon.GrantDay, + CouponTemplateIds = source.MonthlyCoupon.CouponTemplateIds + .Select(StoreApiHelpers.ParseSnowflakeOrNull) + .Where(item => item.HasValue) + .Select(item => item!.Value) + .ToList() + }, + FreeDelivery = new MemberTierFreeDeliveryBenefitDto + { + Enabled = source.FreeDelivery.Enabled, + MonthlyFreeTimes = source.FreeDelivery.MonthlyFreeTimes + }, + PriorityDeliveryEnabled = source.PriorityDeliveryEnabled, + ExclusiveServiceEnabled = source.ExclusiveServiceEnabled + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Commands/DeleteMemberTierCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/Commands/DeleteMemberTierCommand.cs new file mode 100644 index 0000000..7ed90ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Commands/DeleteMemberTierCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Members.Commands; + +/// +/// 删除会员等级命令。 +/// +public sealed class DeleteMemberTierCommand : IRequest +{ + /// + /// 等级标识。 + /// + public long TierId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberDaySettingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberDaySettingCommand.cs new file mode 100644 index 0000000..00e16fe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberDaySettingCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Commands; + +/// +/// 保存会员日配置命令。 +/// +public sealed class SaveMemberDaySettingCommand : IRequest +{ + /// + /// 是否启用会员日。 + /// + public bool IsEnabled { get; init; } + + /// + /// 周几(1-7,对应周一到周日)。 + /// + public int Weekday { get; init; } + + /// + /// 会员日额外折扣(如 9 表示 9 折)。 + /// + public decimal ExtraDiscountRate { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTagsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTagsCommand.cs new file mode 100644 index 0000000..ff40cb0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTagsCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Members.Commands; + +/// +/// 保存会员标签命令。 +/// +public sealed class SaveMemberTagsCommand : IRequest +{ + /// + /// 会员标识。 + /// + public long MemberId { get; init; } + + /// + /// 标签集合。 + /// + public IReadOnlyList Tags { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTierCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTierCommand.cs new file mode 100644 index 0000000..8ac180f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Commands/SaveMemberTierCommand.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Commands; + +/// +/// 保存会员等级命令。 +/// +public sealed class SaveMemberTierCommand : IRequest +{ + /// + /// 等级标识(为空时新增)。 + /// + public long? TierId { get; init; } + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } + + /// + /// 等级名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; init; } = "user"; + + /// + /// 主题色。 + /// + public string ColorHex { get; init; } = "#999999"; + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; init; } + + /// + /// 升降级规则。 + /// + public MemberTierRuleDto Rule { get; init; } = new(); + + /// + /// 等级权益。 + /// + public MemberTierBenefitsDto Benefits { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Dto/MemberDtos.cs b/src/Application/TakeoutSaaS.Application/App/Members/Dto/MemberDtos.cs new file mode 100644 index 0000000..5ff1217 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Dto/MemberDtos.cs @@ -0,0 +1,567 @@ +namespace TakeoutSaaS.Application.App.Members.Dto; + +/// +/// 会员列表行 DTO。 +/// +public sealed class MemberListItemDto +{ + /// + /// 会员标识。 + /// + public long MemberId { get; init; } + + /// + /// 会员名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; init; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; init; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string MobileMasked { get; init; } = string.Empty; + + /// + /// 会员等级标识。 + /// + public long? TierId { get; init; } + + /// + /// 会员等级名称。 + /// + public string TierName { get; init; } = string.Empty; + + /// + /// 等级主题色。 + /// + public string TierColorHex { get; init; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 消费次数。 + /// + public int OrderCount { get; init; } + + /// + /// 最近消费时间。 + /// + public DateTime LastOrderAt { get; init; } + + /// + /// 储值余额。 + /// + public decimal StoredBalance { get; init; } + + /// + /// 积分余额。 + /// + public int PointsBalance { get; init; } + + /// + /// 是否沉睡会员(用于弱化展示)。 + /// + public bool IsDormant { get; init; } +} + +/// +/// 会员列表统计 DTO。 +/// +public sealed class MemberListStatsDto +{ + /// + /// 会员总数。 + /// + public int TotalMembers { get; init; } + + /// + /// 本月新增会员数。 + /// + public int MonthlyNewMembers { get; init; } + + /// + /// 活跃会员数(近 30 天有消费)。 + /// + public int ActiveMembers { get; init; } + + /// + /// 沉睡会员数(超过 60 天未消费)。 + /// + public int DormantMembers { get; init; } +} + +/// +/// 会员最近订单 DTO。 +/// +public sealed class MemberRecentOrderDto +{ + /// + /// 日期。 + /// + public DateTime OrderedAt { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 订单金额。 + /// + public decimal Amount { get; init; } + + /// + /// 订单状态文案。 + /// + public string StatusText { get; init; } = string.Empty; +} + +/// +/// 会员详情 DTO。 +/// +public sealed class MemberDetailDto +{ + /// + /// 会员标识。 + /// + public long MemberId { get; init; } + + /// + /// 会员名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; init; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; init; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string MobileMasked { get; init; } = string.Empty; + + /// + /// 注册时间。 + /// + public DateTime JoinedAt { get; init; } + + /// + /// 会员等级标识。 + /// + public long? TierId { get; init; } + + /// + /// 会员等级名称。 + /// + public string TierName { get; init; } = string.Empty; + + /// + /// 等级主题色。 + /// + public string TierColorHex { get; init; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 消费次数。 + /// + public int OrderCount { get; init; } + + /// + /// 平均客单价。 + /// + public decimal AverageAmount { get; init; } + + /// + /// 储值余额。 + /// + public decimal StoredBalance { get; init; } + + /// + /// 储值实充余额。 + /// + public decimal StoredRechargeBalance { get; init; } + + /// + /// 储值赠金余额。 + /// + public decimal StoredGiftBalance { get; init; } + + /// + /// 积分余额。 + /// + public int PointsBalance { get; init; } + + /// + /// 会员标签。 + /// + public IReadOnlyList Tags { get; init; } = []; + + /// + /// 最近订单。 + /// + public IReadOnlyList RecentOrders { get; init; } = []; +} + +/// +/// 会员列表导出 DTO。 +/// +public sealed class MemberExportDto +{ + /// + /// 文件名。 + /// + public string FileName { get; init; } = string.Empty; + + /// + /// 文件内容 Base64。 + /// + public string FileContentBase64 { get; init; } = string.Empty; + + /// + /// 导出总数。 + /// + public int TotalCount { get; init; } +} + +/// +/// 会员等级列表项 DTO。 +/// +public sealed class MemberTierListItemDto +{ + /// + /// 等级标识。 + /// + public long TierId { get; init; } + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } + + /// + /// 等级名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; init; } = string.Empty; + + /// + /// 主题色。 + /// + public string ColorHex { get; init; } = string.Empty; + + /// + /// 升级条件文案。 + /// + public string ConditionText { get; init; } = string.Empty; + + /// + /// 权益摘要。 + /// + public IReadOnlyList Perks { get; init; } = []; + + /// + /// 等级会员数。 + /// + public int MemberCount { get; init; } + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; init; } + + /// + /// 是否可删除。 + /// + public bool CanDelete { get; init; } +} + +/// +/// 会员等级规则 DTO。 +/// +public sealed class MemberTierRuleDto +{ + /// + /// 升级规则类型(none/amount/count/both)。 + /// + public string UpgradeRuleType { get; init; } = "none"; + + /// + /// 升级累计消费门槛。 + /// + public decimal? UpgradeAmountThreshold { get; init; } + + /// + /// 升级消费次数门槛。 + /// + public int? UpgradeOrderCountThreshold { get; init; } + + /// + /// 降级观察窗口天数。 + /// + public int DowngradeWindowDays { get; init; } = 90; +} + +/// +/// 会员等级权益 DTO。 +/// +public sealed class MemberTierBenefitsDto +{ + /// + /// 折扣权益。 + /// + public MemberTierDiscountBenefitDto Discount { get; init; } = new(); + + /// + /// 积分倍率权益。 + /// + public MemberTierPointMultiplierBenefitDto PointMultiplier { get; init; } = new(); + + /// + /// 生日特权。 + /// + public MemberTierBirthdayBenefitDto Birthday { get; init; } = new(); + + /// + /// 每月赠券。 + /// + public MemberTierMonthlyCouponBenefitDto MonthlyCoupon { get; init; } = new(); + + /// + /// 免配送费权益。 + /// + public MemberTierFreeDeliveryBenefitDto FreeDelivery { get; init; } = new(); + + /// + /// 优先配送。 + /// + public bool PriorityDeliveryEnabled { get; init; } + + /// + /// 专属客服。 + /// + public bool ExclusiveServiceEnabled { get; init; } +} + +/// +/// 折扣权益 DTO。 +/// +public sealed class MemberTierDiscountBenefitDto +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; init; } + + /// + /// 折扣值(如 9.5)。 + /// + public decimal? DiscountRate { get; init; } +} + +/// +/// 积分倍率权益 DTO。 +/// +public sealed class MemberTierPointMultiplierBenefitDto +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; init; } + + /// + /// 倍率(如 1.5)。 + /// + public decimal? Multiplier { get; init; } +} + +/// +/// 生日特权 DTO。 +/// +public sealed class MemberTierBirthdayBenefitDto +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; init; } + + /// + /// 是否双倍积分。 + /// + public bool DoublePointsEnabled { get; init; } + + /// + /// 生日赠券模板 ID。 + /// + public IReadOnlyList CouponTemplateIds { get; init; } = []; +} + +/// +/// 每月赠券 DTO。 +/// +public sealed class MemberTierMonthlyCouponBenefitDto +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; init; } + + /// + /// 每月发放日(1-28)。 + /// + public int GrantDay { get; init; } = 1; + + /// + /// 每月赠券模板 ID。 + /// + public IReadOnlyList CouponTemplateIds { get; init; } = []; +} + +/// +/// 免配送费权益 DTO。 +/// +public sealed class MemberTierFreeDeliveryBenefitDto +{ + /// + /// 是否启用。 + /// + public bool Enabled { get; init; } + + /// + /// 每月免配送费次数。 + /// + public int MonthlyFreeTimes { get; init; } +} + +/// +/// 会员等级详情 DTO。 +/// +public sealed class MemberTierDetailDto +{ + /// + /// 等级标识。 + /// + public long? TierId { get; init; } + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } + + /// + /// 等级名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 图标键。 + /// + public string IconKey { get; init; } = string.Empty; + + /// + /// 主题色。 + /// + public string ColorHex { get; init; } = string.Empty; + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; init; } + + /// + /// 升降级规则。 + /// + public MemberTierRuleDto Rule { get; init; } = new(); + + /// + /// 等级权益。 + /// + public MemberTierBenefitsDto Benefits { get; init; } = new(); + + /// + /// 是否可删除。 + /// + public bool CanDelete { get; init; } +} + +/// +/// 会员日配置 DTO。 +/// +public sealed class MemberDaySettingDto +{ + /// + /// 是否启用会员日。 + /// + public bool IsEnabled { get; init; } + + /// + /// 周几(1-7,对应周一到周日)。 + /// + public int Weekday { get; init; } + + /// + /// 会员日额外折扣(如 9 表示 9 折)。 + /// + public decimal ExtraDiscountRate { get; init; } +} + +/// +/// 优惠券选择项 DTO。 +/// +public sealed class MemberCouponPickerItemDto +{ + /// + /// 券模板标识。 + /// + public long CouponTemplateId { get; init; } + + /// + /// 券名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 券类型。 + /// + public string CouponType { get; init; } = string.Empty; + + /// + /// 面值或折扣值。 + /// + public decimal Value { get; init; } + + /// + /// 最低消费门槛。 + /// + public decimal? MinimumSpend { get; init; } + + /// + /// 展示文案。 + /// + public string DisplayText { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/DeleteMemberTierCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/DeleteMemberTierCommandHandler.cs new file mode 100644 index 0000000..8f1003c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/DeleteMemberTierCommandHandler.cs @@ -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; + +/// +/// 删除会员等级命令处理器。 +/// +public sealed class DeleteMemberTierCommandHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + 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); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/ExportMemberCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/ExportMemberCsvQueryHandler.cs new file mode 100644 index 0000000..8e9d9d5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/ExportMemberCsvQueryHandler.cs @@ -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; + +/// +/// 会员列表 CSV 导出查询处理器。 +/// +public sealed class ExportMemberCsvQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDaySettingQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDaySettingQueryHandler.cs new file mode 100644 index 0000000..20a0307 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDaySettingQueryHandler.cs @@ -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; + +/// +/// 会员日配置查询处理器。 +/// +public sealed class GetMemberDaySettingQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDetailQueryHandler.cs new file mode 100644 index 0000000..b4dcb8b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberDetailQueryHandler.cs @@ -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; + +/// +/// 会员详情查询处理器。 +/// +public sealed class GetMemberDetailQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberListStatsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberListStatsQueryHandler.cs new file mode 100644 index 0000000..a6e7abe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberListStatsQueryHandler.cs @@ -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; + +/// +/// 会员列表统计查询处理器。 +/// +public sealed class GetMemberListStatsQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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)) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierDetailQueryHandler.cs new file mode 100644 index 0000000..8165e45 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierDetailQueryHandler.cs @@ -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; + +/// +/// 会员等级详情查询处理器。 +/// +public sealed class GetMemberTierDetailQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierListQueryHandler.cs new file mode 100644 index 0000000..e309118 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/GetMemberTierListQueryHandler.cs @@ -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; + +/// +/// 会员等级列表查询处理器。 +/// +public sealed class GetMemberTierListQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/MemberCenterSupport.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/MemberCenterSupport.cs new file mode 100644 index 0000000..ef51669 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/MemberCenterSupport.cs @@ -0,0 +1,1108 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using TakeoutSaaS.Application.App.Members.Dto; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.Handlers; + +internal static class MemberCenterSupport +{ + private static readonly string[] AvatarColors = + [ + "#1890ff", + "#52c41a", + "#fa8c16", + "#722ed1", + "#eb2f96", + "#13c2c2", + "#2f54eb", + "#f56a00" + ]; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + internal static string NormalizePhone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var chars = value.Where(char.IsDigit).ToArray(); + return chars.Length == 0 ? string.Empty : new string(chars); + } + + internal static string MaskPhone(string normalizedPhone) + { + if (normalizedPhone.Length >= 11) + { + return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}"; + } + + if (normalizedPhone.Length >= 7) + { + return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}"; + } + + return normalizedPhone; + } + + internal static string ResolveAvatarColor(string? seed) + { + var source = string.IsNullOrWhiteSpace(seed) ? "member" : seed; + var hash = 0; + foreach (var ch in source) + { + hash = (hash * 31 + ch) & int.MaxValue; + } + + return AvatarColors[hash % AvatarColors.Length]; + } + + internal static string ResolveAvatarText(string? name, string fallbackPhone) + { + var normalizedName = (name ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(normalizedName)) + { + return normalizedName[..1]; + } + + return fallbackPhone.Length > 0 ? fallbackPhone[..1] : "会"; + } + + internal static string ResolveDisplayName(MemberProfile profile, MemberOrderMetrics metrics) + { + if (!string.IsNullOrWhiteSpace(profile.Nickname)) + { + return profile.Nickname.Trim(); + } + + if (!string.IsNullOrWhiteSpace(metrics.LatestCustomerName)) + { + return metrics.LatestCustomerName; + } + + return profile.Mobile.Length >= 4 ? $"会员{profile.Mobile[^4..]}" : "会员"; + } + + internal static decimal ResolveDisplayAmount(Order order) + { + return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount; + } + + internal static string ResolveOrderStatusText(OrderStatus value) + { + return value switch + { + OrderStatus.PendingPayment => "待付款", + OrderStatus.AwaitingPreparation => "待接单", + OrderStatus.InProgress => "制作中", + OrderStatus.Ready => "待取餐", + OrderStatus.Completed => "已完成", + OrderStatus.Cancelled => "已取消", + _ => "未知" + }; + } + + internal static string NormalizeRuleType(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "amount" => "amount", + "count" => "count", + "both" => "both", + _ => "none" + }; + } + + internal static MemberTierBenefitsDto NormalizeBenefits(MemberTierBenefitsDto? source) + { + var model = source ?? new MemberTierBenefitsDto(); + + var discountRate = model.Discount.DiscountRate; + if (discountRate.HasValue) + { + discountRate = Math.Clamp(decimal.Round(discountRate.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m); + } + + var multiplier = model.PointMultiplier.Multiplier; + if (multiplier.HasValue) + { + multiplier = Math.Clamp(decimal.Round(multiplier.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m); + } + + var monthlyGrantDay = Math.Clamp(model.MonthlyCoupon.GrantDay <= 0 ? 1 : model.MonthlyCoupon.GrantDay, 1, 28); + var monthlyFreeTimes = Math.Max(0, model.FreeDelivery.MonthlyFreeTimes); + + return new MemberTierBenefitsDto + { + Discount = new MemberTierDiscountBenefitDto + { + Enabled = model.Discount.Enabled, + DiscountRate = discountRate + }, + PointMultiplier = new MemberTierPointMultiplierBenefitDto + { + Enabled = model.PointMultiplier.Enabled, + Multiplier = multiplier + }, + Birthday = new MemberTierBirthdayBenefitDto + { + Enabled = model.Birthday.Enabled, + DoublePointsEnabled = model.Birthday.DoublePointsEnabled, + CouponTemplateIds = NormalizeCouponIds(model.Birthday.CouponTemplateIds) + }, + MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto + { + Enabled = model.MonthlyCoupon.Enabled, + GrantDay = monthlyGrantDay, + CouponTemplateIds = NormalizeCouponIds(model.MonthlyCoupon.CouponTemplateIds) + }, + FreeDelivery = new MemberTierFreeDeliveryBenefitDto + { + Enabled = model.FreeDelivery.Enabled, + MonthlyFreeTimes = monthlyFreeTimes + }, + PriorityDeliveryEnabled = model.PriorityDeliveryEnabled, + ExclusiveServiceEnabled = model.ExclusiveServiceEnabled + }; + } + + internal static MemberTierBenefitsDto DeserializeBenefits(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new MemberTierBenefitsDto(); + } + + try + { + var model = JsonSerializer.Deserialize(value, JsonOptions); + return NormalizeBenefits(model); + } + catch + { + return new MemberTierBenefitsDto(); + } + } + + internal static string SerializeBenefits(MemberTierBenefitsDto benefits) + { + return JsonSerializer.Serialize(NormalizeBenefits(benefits), JsonOptions); + } + + internal static string BuildConditionText(MemberTier tier) + { + var ruleType = NormalizeRuleType(tier.UpgradeRuleType); + return ruleType switch + { + "amount" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)}", + "count" => $"累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次", + "both" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)} 且累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次", + _ => "注册即享" + }; + } + + internal static IReadOnlyList BuildPerks(MemberTier tier, MemberTierBenefitsDto benefits) + { + var result = new List(); + + if (benefits.Discount.Enabled && benefits.Discount.DiscountRate.HasValue) + { + result.Add($"全场{benefits.Discount.DiscountRate.Value:0.##}折"); + } + + if (benefits.Birthday.Enabled) + { + if (benefits.Birthday.DoublePointsEnabled && benefits.Birthday.CouponTemplateIds.Count > 0) + { + result.Add("生日赠券+双倍积分"); + } + else if (benefits.Birthday.DoublePointsEnabled) + { + result.Add("生日双倍积分"); + } + else if (benefits.Birthday.CouponTemplateIds.Count > 0) + { + result.Add("生日赠券"); + } + else + { + result.Add("生日特权"); + } + } + + if (benefits.MonthlyCoupon.Enabled) + { + result.Add("每月赠券"); + } + + if (benefits.PointMultiplier.Enabled && benefits.PointMultiplier.Multiplier.HasValue && benefits.PointMultiplier.Multiplier.Value > 1) + { + result.Add($"积分{benefits.PointMultiplier.Multiplier.Value:0.##}倍"); + } + + if (benefits.FreeDelivery.Enabled) + { + result.Add("免配送费"); + } + + if (benefits.PriorityDeliveryEnabled) + { + result.Add("优先配送"); + } + + if (benefits.ExclusiveServiceEnabled) + { + result.Add("专属客服"); + } + + if (result.Count == 0 && tier.IsDefault) + { + return ["积分累计", "会员价商品"]; + } + + return result; + } + + internal static async Task EnsureMemberCenterInitializedAsync( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + if (tenantId <= 0) + { + return; + } + + var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); + var changed = false; + + if (tiers.Count == 0) + { + foreach (var seed in BuildDefaultTierSeeds()) + { + await memberRepository.AddTierAsync(seed, cancellationToken); + } + + changed = true; + } + + var memberDaySetting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken); + if (memberDaySetting is null) + { + await memberRepository.AddMemberDaySettingAsync(new MemberDaySetting + { + IsEnabled = true, + Weekday = 2, + ExtraDiscountRate = 9m + }, cancellationToken); + changed = true; + } + + if (changed) + { + await memberRepository.SaveChangesAsync(cancellationToken); + tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); + } + + var allOrders = await orderRepository.SearchAllOrdersAsync( + tenantId, + null, + null, + null, + null, + null, + null, + null, + cancellationToken); + + var validOrders = allOrders + .Where(IsQualifiedOrder) + .Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone))) + .ToList(); + + if (validOrders.Count == 0) + { + return; + } + + var nowUtc = DateTime.UtcNow; + var orderMetricsMap = BuildOrderMetrics(validOrders, nowUtc); + var mobileKeys = orderMetricsMap.Keys.ToList(); + var existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken); + var profileLookup = existedProfiles.ToDictionary( + item => NormalizePhone(item.Mobile), + item => item, + StringComparer.Ordinal); + + var defaultTier = ResolveDefaultTier(tiers); + var profilesToAdd = new List(); + + foreach (var (mobile, metrics) in orderMetricsMap) + { + if (profileLookup.ContainsKey(mobile)) + { + continue; + } + + profilesToAdd.Add(new MemberProfile + { + UserId = 0, + Mobile = mobile, + Nickname = string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? null : metrics.LatestCustomerName, + MemberTierId = defaultTier?.Id, + StoredBalance = 0, + StoredRechargeBalance = 0, + StoredGiftBalance = 0, + Status = MemberStatus.Active, + PointsBalance = 0, + GrowthValue = ToGrowthValue(metrics.TotalAmount), + JoinedAt = metrics.FirstOrderAt + }); + } + + if (profilesToAdd.Count > 0) + { + await memberRepository.AddProfilesAsync(profilesToAdd, cancellationToken); + await memberRepository.SaveChangesAsync(cancellationToken); + existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken); + profileLookup = existedProfiles.ToDictionary( + item => NormalizePhone(item.Mobile), + item => item, + StringComparer.Ordinal); + } + + if (profileLookup.Count == 0) + { + return; + } + + var tierLookup = tiers.ToDictionary(item => item.Id); + var orderedTiers = tiers + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Id) + .ToList(); + + var hasProfileChanged = false; + foreach (var (mobile, profile) in profileLookup) + { + if (!orderMetricsMap.TryGetValue(mobile, out var metrics)) + { + continue; + } + + var targetTier = ResolveTargetTier(profile, orderedTiers, tierLookup, metrics, nowUtc); + var targetJoinedAt = profile.JoinedAt == default + ? metrics.FirstOrderAt + : (profile.JoinedAt > metrics.FirstOrderAt ? metrics.FirstOrderAt : profile.JoinedAt); + var targetGrowth = ToGrowthValue(metrics.TotalAmount); + var targetNickname = string.IsNullOrWhiteSpace(profile.Nickname) + ? (string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? profile.Nickname : metrics.LatestCustomerName) + : profile.Nickname; + + if (profile.MemberTierId != targetTier?.Id || + profile.GrowthValue != targetGrowth || + profile.JoinedAt != targetJoinedAt || + profile.Nickname != targetNickname) + { + profile.MemberTierId = targetTier?.Id; + profile.GrowthValue = targetGrowth; + profile.JoinedAt = targetJoinedAt; + profile.Nickname = targetNickname; + await memberRepository.UpdateProfileAsync(profile, cancellationToken); + hasProfileChanged = true; + } + } + + if (hasProfileChanged) + { + await memberRepository.SaveChangesAsync(cancellationToken); + } + } + + internal static async Task LoadMemberContextAsync( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider, + IReadOnlyCollection visibleStoreIds, + CancellationToken cancellationToken) + { + await EnsureMemberCenterInitializedAsync(memberRepository, orderRepository, tenantProvider, cancellationToken); + + if (visibleStoreIds.Count == 0) + { + return new MemberContextSnapshot(); + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); + var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken); + + if (tiers.Count == 0 || profiles.Count == 0) + { + return new MemberContextSnapshot + { + TierLookup = tiers.ToDictionary(item => item.Id), + Aggregates = [] + }; + } + + var orders = await orderRepository.SearchAllOrdersAsync( + tenantId, + null, + null, + null, + null, + null, + null, + null, + cancellationToken); + + var visibleStoreSet = visibleStoreIds.ToHashSet(); + var visibleOrders = orders + .Where(IsQualifiedOrder) + .Where(order => visibleStoreSet.Contains(order.StoreId)) + .Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone))) + .ToList(); + + if (visibleOrders.Count == 0) + { + return new MemberContextSnapshot + { + TierLookup = tiers.ToDictionary(item => item.Id), + Aggregates = [] + }; + } + + var metricsMap = BuildOrderMetrics(visibleOrders, DateTime.UtcNow); + var tierLookup = tiers.ToDictionary(item => item.Id); + var defaultTier = ResolveDefaultTier(tiers); + + var aggregates = new List(); + foreach (var profile in profiles) + { + var mobile = NormalizePhone(profile.Mobile); + if (string.IsNullOrWhiteSpace(mobile) || !metricsMap.TryGetValue(mobile, out var metrics)) + { + continue; + } + + var tier = profile.MemberTierId.HasValue && tierLookup.TryGetValue(profile.MemberTierId.Value, out var matchedTier) + ? matchedTier + : defaultTier; + var name = ResolveDisplayName(profile, metrics); + var avatarText = ResolveAvatarText(name, mobile); + var avatarColor = ResolveAvatarColor(mobile); + var joinedAt = profile.JoinedAt == default ? metrics.FirstOrderAt : profile.JoinedAt; + + aggregates.Add(new MemberAggregate + { + MemberId = profile.Id, + Mobile = mobile, + MobileMasked = MaskPhone(mobile), + Name = name, + AvatarText = avatarText, + AvatarColor = avatarColor, + JoinedAt = joinedAt, + Tier = tier, + Profile = profile, + Metrics = metrics, + IsDormant = metrics.LastOrderAt < DateTime.UtcNow.AddDays(-60) + }); + } + + return new MemberContextSnapshot + { + TierLookup = tierLookup, + Aggregates = aggregates + .OrderByDescending(item => item.Metrics.LastOrderAt) + .ThenByDescending(item => item.MemberId) + .ToList() + }; + } + + internal static IReadOnlyList ApplyFilters( + IReadOnlyList source, + string? keyword, + long? tierId) + { + var normalizedKeyword = (keyword ?? string.Empty).Trim(); + var keywordDigits = NormalizePhone(normalizedKeyword); + + return source + .Where(item => + { + if (tierId.HasValue) + { + if (!item.Tier?.Id.Equals(tierId.Value) ?? true) + { + return false; + } + } + + if (string.IsNullOrWhiteSpace(normalizedKeyword)) + { + return true; + } + + var matchedByName = item.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase); + var matchedByMobile = !string.IsNullOrWhiteSpace(keywordDigits) && + item.Mobile.Contains(keywordDigits, StringComparison.Ordinal); + return matchedByName || matchedByMobile; + }) + .ToList(); + } + + internal static string BuildCsv(IReadOnlyList items) + { + var builder = new StringBuilder(); + builder.AppendLine("会员,手机号,等级,累计消费,消费次数,最近消费,储值余额,积分"); + + foreach (var item in items) + { + builder.Append(EscapeCsv(item.Name)); + builder.Append(','); + builder.Append(EscapeCsv(item.MobileMasked)); + builder.Append(','); + builder.Append(EscapeCsv(item.TierName)); + builder.Append(','); + builder.Append(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)); + builder.Append(','); + builder.Append(item.OrderCount.ToString(CultureInfo.InvariantCulture)); + builder.Append(','); + builder.Append(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + builder.Append(','); + builder.Append(item.StoredBalance.ToString("0.00", CultureInfo.InvariantCulture)); + builder.Append(','); + builder.Append(item.PointsBalance.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine(); + } + + return builder.ToString(); + } + + internal static bool IsCouponVisibleToStores(CouponTemplate template, IReadOnlyCollection visibleStoreIds) + { + if (visibleStoreIds.Count == 0) + { + return false; + } + + var visibleStoreSet = visibleStoreIds.ToHashSet(); + + if (string.IsNullOrWhiteSpace(template.StoreScopeJson)) + { + return true; + } + + try + { + using var document = JsonDocument.Parse(template.StoreScopeJson); + if (!document.RootElement.TryGetProperty("mode", out var modeElement)) + { + return true; + } + + var mode = modeElement.GetString()?.Trim().ToLowerInvariant() ?? "stores"; + if (mode == "all") + { + return true; + } + + if (!document.RootElement.TryGetProperty("storeIds", out var idsElement) || idsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var item in idsElement.EnumerateArray()) + { + if (item.TryGetInt64(out var storeId) && visibleStoreSet.Contains(storeId)) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + + internal static bool IsCouponActive(CouponTemplate template, DateTime nowUtc) + { + if (template.Status != CouponTemplateStatus.Active) + { + return false; + } + + if (template.ValidFrom.HasValue && template.ValidFrom.Value > nowUtc) + { + return false; + } + + if (template.ValidTo.HasValue && template.ValidTo.Value < nowUtc) + { + return false; + } + + return true; + } + + internal static string ResolveCouponTypeText(CouponType couponType) + { + return couponType switch + { + CouponType.AmountOff => "amount_off", + CouponType.Percentage => "discount", + CouponType.DeliveryFee => "free_delivery", + CouponType.Cash => "cash", + CouponType.Gift => "gift", + _ => "amount_off" + }; + } + + internal static string BuildCouponDisplayText(CouponTemplate template) + { + return template.CouponType switch + { + CouponType.AmountOff => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0 + ? $"满{template.MinimumSpend.Value:0.##}减{template.Value:0.##}" + : $"减{template.Value:0.##}", + CouponType.Percentage => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0 + ? $"满{template.MinimumSpend.Value:0.##}享{template.Value:0.##}折" + : $"{template.Value:0.##}折", + CouponType.DeliveryFee => "免配送费", + CouponType.Cash => $"现金券 {template.Value:0.##}", + CouponType.Gift => "礼品券", + _ => template.Name + }; + } + + internal static MemberTierDetailDto ToTierDetailDto(MemberTier tier, bool canDelete) + { + var benefits = DeserializeBenefits(tier.BenefitsJson); + return new MemberTierDetailDto + { + TierId = tier.Id, + SortOrder = tier.SortOrder, + Name = tier.Name, + IconKey = tier.IconKey, + ColorHex = tier.ColorHex, + IsDefault = tier.IsDefault, + Rule = new MemberTierRuleDto + { + UpgradeRuleType = NormalizeRuleType(tier.UpgradeRuleType), + UpgradeAmountThreshold = tier.UpgradeAmountThreshold, + UpgradeOrderCountThreshold = tier.UpgradeOrderCountThreshold, + DowngradeWindowDays = tier.DowngradeWindowDays + }, + Benefits = benefits, + CanDelete = canDelete + }; + } + + internal static MemberDaySettingDto ToMemberDaySettingDto(MemberDaySetting setting) + { + return new MemberDaySettingDto + { + IsEnabled = setting.IsEnabled, + Weekday = setting.Weekday, + ExtraDiscountRate = setting.ExtraDiscountRate + }; + } + + private static string EscapeCsv(string? value) + { + var source = value ?? string.Empty; + if (!source.Contains(',') && !source.Contains('"') && !source.Contains('\n')) + { + return source; + } + + return $"\"{source.Replace("\"", "\"\"")}\""; + } + + private static IReadOnlyList NormalizeCouponIds(IReadOnlyList? source) + { + return (source ?? []) + .Where(item => item > 0) + .Distinct() + .OrderBy(item => item) + .ToList(); + } + + private static string FormatAmount(decimal? value) + { + var amount = Math.Max(0m, value ?? 0m); + return $"¥{amount:0.##}"; + } + + private static bool IsQualifiedOrder(Order order) + { + if (order.Status == OrderStatus.Cancelled) + { + return false; + } + + if (order.PaymentStatus is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Failed) + { + return order.PaidAmount > 0; + } + + return true; + } + + private static int ToGrowthValue(decimal totalAmount) + { + var value = decimal.Round(totalAmount, 0, MidpointRounding.AwayFromZero); + if (value > int.MaxValue) + { + return int.MaxValue; + } + + if (value < 0) + { + return 0; + } + + return (int)value; + } + + private static MemberTier? ResolveDefaultTier(IReadOnlyList tiers) + { + return tiers + .OrderByDescending(item => item.IsDefault) + .ThenBy(item => item.SortOrder) + .ThenBy(item => item.Id) + .FirstOrDefault(); + } + + private static MemberTier? ResolveTargetTier( + MemberProfile profile, + IReadOnlyList orderedTiers, + IReadOnlyDictionary tierLookup, + MemberOrderMetrics metrics, + DateTime nowUtc) + { + if (orderedTiers.Count == 0) + { + return null; + } + + var defaultTier = ResolveDefaultTier(orderedTiers) ?? orderedTiers[0]; + var candidateTier = ResolveTierByMetrics(orderedTiers, metrics.TotalAmount, metrics.TotalOrderCount) ?? defaultTier; + + if (!profile.MemberTierId.HasValue || !tierLookup.TryGetValue(profile.MemberTierId.Value, out var currentTier)) + { + return candidateTier; + } + + if (candidateTier.SortOrder > currentTier.SortOrder) + { + return candidateTier; + } + + var downgradeWindowDays = currentTier.DowngradeWindowDays > 0 ? currentTier.DowngradeWindowDays : 90; + var windowStart = nowUtc.AddDays(-downgradeWindowDays); + var windowOrders = metrics.Orders + .Where(item => item.OrderedAt >= windowStart) + .ToList(); + + var windowAmount = windowOrders.Sum(item => item.Amount); + var windowOrderCount = windowOrders.Count; + + if (MatchTierRule(currentTier, windowAmount, windowOrderCount)) + { + return currentTier; + } + + var previousTier = orderedTiers + .Where(item => item.SortOrder < currentTier.SortOrder) + .OrderByDescending(item => item.SortOrder) + .ThenByDescending(item => item.Id) + .FirstOrDefault(); + + if (previousTier is null) + { + return defaultTier; + } + + return previousTier; + } + + private static MemberTier? ResolveTierByMetrics( + IReadOnlyList orderedTiers, + decimal totalAmount, + int totalOrderCount) + { + MemberTier? matched = null; + foreach (var tier in orderedTiers) + { + if (MatchTierRule(tier, totalAmount, totalOrderCount)) + { + matched = tier; + } + } + + return matched; + } + + private static bool MatchTierRule(MemberTier tier, decimal amount, int orderCount) + { + var ruleType = NormalizeRuleType(tier.UpgradeRuleType); + var thresholdAmount = Math.Max(0m, tier.UpgradeAmountThreshold ?? 0m); + var thresholdCount = Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0); + + return ruleType switch + { + "amount" => amount >= thresholdAmount, + "count" => orderCount >= thresholdCount, + "both" => amount >= thresholdAmount && orderCount >= thresholdCount, + _ => true + }; + } + + private static Dictionary BuildOrderMetrics( + IReadOnlyList orders, + DateTime nowUtc) + { + return orders + .GroupBy(item => NormalizePhone(item.CustomerPhone), StringComparer.Ordinal) + .Where(group => !string.IsNullOrWhiteSpace(group.Key)) + .ToDictionary( + group => group.Key, + group => + { + var sorted = group + .OrderByDescending(item => item.CreatedAt) + .ThenByDescending(item => item.Id) + .ToList(); + + var totalAmount = sorted.Sum(ResolveDisplayAmount); + var totalOrderCount = sorted.Count; + var latestCustomerName = sorted + .Select(item => (item.CustomerName ?? string.Empty).Trim()) + .FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)) ?? string.Empty; + + var snapshots = sorted + .Select(item => new MemberOrderSnapshot + { + OrderId = item.Id, + OrderNo = item.OrderNo, + OrderedAt = item.CreatedAt, + Amount = ResolveDisplayAmount(item), + Status = item.Status + }) + .ToList(); + + return new MemberOrderMetrics + { + Mobile = group.Key, + TotalAmount = totalAmount, + TotalOrderCount = totalOrderCount, + FirstOrderAt = sorted.Min(item => item.CreatedAt), + LastOrderAt = sorted.Max(item => item.CreatedAt), + LatestCustomerName = latestCustomerName, + Orders = snapshots + }; + }, + StringComparer.Ordinal); + } + + private static IReadOnlyList BuildDefaultTierSeeds() + { + var defaults = new List + { + new() + { + Name = "普通会员", + RequiredGrowth = 0, + IconKey = "user", + ColorHex = "#999999", + UpgradeRuleType = "none", + UpgradeAmountThreshold = null, + UpgradeOrderCountThreshold = null, + DowngradeWindowDays = 90, + IsDefault = true, + SortOrder = 1, + BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto()) + }, + new() + { + Name = "银卡会员", + RequiredGrowth = 500, + IconKey = "award", + ColorHex = "#1890ff", + UpgradeRuleType = "amount", + UpgradeAmountThreshold = 500m, + UpgradeOrderCountThreshold = null, + DowngradeWindowDays = 90, + IsDefault = false, + SortOrder = 2, + BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto + { + Discount = new MemberTierDiscountBenefitDto + { + Enabled = true, + DiscountRate = 9.8m + }, + PointMultiplier = new MemberTierPointMultiplierBenefitDto + { + Enabled = true, + Multiplier = 1.2m + }, + Birthday = new MemberTierBirthdayBenefitDto + { + Enabled = true, + DoublePointsEnabled = false, + CouponTemplateIds = [] + } + }) + }, + new() + { + Name = "金卡会员", + RequiredGrowth = 2000, + IconKey = "trophy", + ColorHex = "#fa8c16", + UpgradeRuleType = "amount", + UpgradeAmountThreshold = 2000m, + UpgradeOrderCountThreshold = null, + DowngradeWindowDays = 90, + IsDefault = false, + SortOrder = 3, + BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto + { + Discount = new MemberTierDiscountBenefitDto + { + Enabled = true, + DiscountRate = 9.5m + }, + PointMultiplier = new MemberTierPointMultiplierBenefitDto + { + Enabled = true, + Multiplier = 1.5m + }, + Birthday = new MemberTierBirthdayBenefitDto + { + Enabled = true, + DoublePointsEnabled = true, + CouponTemplateIds = [] + }, + MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto + { + Enabled = true, + GrantDay = 1, + CouponTemplateIds = [] + } + }) + }, + new() + { + Name = "钻石会员", + RequiredGrowth = 5000, + IconKey = "gem", + ColorHex = "#722ed1", + UpgradeRuleType = "amount", + UpgradeAmountThreshold = 5000m, + UpgradeOrderCountThreshold = null, + DowngradeWindowDays = 90, + IsDefault = false, + SortOrder = 4, + BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto + { + Discount = new MemberTierDiscountBenefitDto + { + Enabled = true, + DiscountRate = 9m + }, + PointMultiplier = new MemberTierPointMultiplierBenefitDto + { + Enabled = true, + Multiplier = 2m + }, + Birthday = new MemberTierBirthdayBenefitDto + { + Enabled = true, + DoublePointsEnabled = true, + CouponTemplateIds = [] + }, + MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto + { + Enabled = true, + GrantDay = 1, + CouponTemplateIds = [] + }, + FreeDelivery = new MemberTierFreeDeliveryBenefitDto + { + Enabled = true, + MonthlyFreeTimes = 5 + }, + PriorityDeliveryEnabled = true, + ExclusiveServiceEnabled = true + }) + } + }; + + return defaults; + } +} + +internal sealed class MemberContextSnapshot +{ + internal IReadOnlyList Aggregates { get; init; } = []; + internal IReadOnlyDictionary TierLookup { get; init; } = new Dictionary(); +} + +internal sealed class MemberAggregate +{ + internal string AvatarColor { get; init; } = string.Empty; + internal string AvatarText { get; init; } = string.Empty; + internal long MemberId { get; init; } + internal MemberOrderMetrics Metrics { get; init; } = new(); + internal string Mobile { get; init; } = string.Empty; + internal string MobileMasked { get; init; } = string.Empty; + internal string Name { get; init; } = string.Empty; + internal MemberProfile Profile { get; init; } = new(); + internal MemberTier? Tier { get; init; } + internal DateTime JoinedAt { get; init; } + internal bool IsDormant { get; init; } +} + +internal sealed class MemberOrderMetrics +{ + internal DateTime FirstOrderAt { get; init; } + internal DateTime LastOrderAt { get; init; } + internal string LatestCustomerName { get; init; } = string.Empty; + internal string Mobile { get; init; } = string.Empty; + internal IReadOnlyList Orders { get; init; } = []; + internal decimal TotalAmount { get; init; } + internal int TotalOrderCount { get; init; } +} + +internal sealed class MemberOrderSnapshot +{ + internal decimal Amount { get; init; } + internal long OrderId { get; init; } + internal string OrderNo { get; init; } = string.Empty; + internal DateTime OrderedAt { get; init; } + internal OrderStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberDaySettingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberDaySettingCommandHandler.cs new file mode 100644 index 0000000..5c13e53 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberDaySettingCommandHandler.cs @@ -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; + +/// +/// 保存会员日配置命令处理器。 +/// +public sealed class SaveMemberDaySettingCommandHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTagsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTagsCommandHandler.cs new file mode 100644 index 0000000..aeea3af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTagsCommandHandler.cs @@ -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; + +/// +/// 保存会员标签命令处理器。 +/// +public sealed class SaveMemberTagsCommandHandler( + IMemberRepository memberRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTierCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTierCommandHandler.cs new file mode 100644 index 0000000..b1ad086 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SaveMemberTierCommandHandler.cs @@ -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; + +/// +/// 保存会员等级命令处理器。 +/// +public sealed class SaveMemberTierCommandHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberCouponPickerQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberCouponPickerQueryHandler.cs new file mode 100644 index 0000000..b0a72c9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberCouponPickerQueryHandler.cs @@ -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; + +/// +/// 优惠券选择器查询处理器。 +/// +public sealed class SearchMemberCouponPickerQueryHandler( + ICouponRepository couponRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberListQueryHandler.cs new file mode 100644 index 0000000..8599bf1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Handlers/SearchMemberListQueryHandler.cs @@ -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; + +/// +/// 会员列表查询处理器。 +/// +public sealed class SearchMemberListQueryHandler( + IMemberRepository memberRepository, + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> 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([], 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(items, page, pageSize, totalCount); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/ExportMemberCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/ExportMemberCsvQuery.cs new file mode 100644 index 0000000..32f4c11 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/ExportMemberCsvQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员列表 CSV 导出查询。 +/// +public sealed class ExportMemberCsvQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; init; } + + /// + /// 等级标识。 + /// + public long? TierId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDaySettingQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDaySettingQuery.cs new file mode 100644 index 0000000..8d529d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDaySettingQuery.cs @@ -0,0 +1,11 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员日配置查询。 +/// +public sealed class GetMemberDaySettingQuery : IRequest +{ +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDetailQuery.cs new file mode 100644 index 0000000..ea5fce5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员详情查询。 +/// +public sealed class GetMemberDetailQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 会员标识。 + /// + public long MemberId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberListStatsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberListStatsQuery.cs new file mode 100644 index 0000000..0ea65c3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberListStatsQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员列表统计查询。 +/// +public sealed class GetMemberListStatsQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; init; } + + /// + /// 等级标识。 + /// + public long? TierId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierDetailQuery.cs new file mode 100644 index 0000000..ce7c2aa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员等级详情查询。 +/// +public sealed class GetMemberTierDetailQuery : IRequest +{ + /// + /// 等级标识(为空时返回新增默认模板)。 + /// + public long? TierId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierListQuery.cs new file mode 100644 index 0000000..8cbba55 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/GetMemberTierListQuery.cs @@ -0,0 +1,11 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员等级列表查询。 +/// +public sealed class GetMemberTierListQuery : IRequest> +{ +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberCouponPickerQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberCouponPickerQuery.cs new file mode 100644 index 0000000..b07901a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberCouponPickerQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 优惠券选择器查询。 +/// +public sealed class SearchMemberCouponPickerQuery : IRequest> +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 搜索关键词。 + /// + public string? Keyword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberListQuery.cs new file mode 100644 index 0000000..d2e56e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/Queries/SearchMemberListQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Members.Queries; + +/// +/// 会员列表查询。 +/// +public sealed class SearchMemberListQuery : IRequest> +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; init; } + + /// + /// 等级标识。 + /// + public long? TierId { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberDaySetting.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberDaySetting.cs new file mode 100644 index 0000000..f13ef06 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberDaySetting.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员日配置。 +/// +public sealed class MemberDaySetting : MultiTenantEntityBase +{ + /// + /// 是否启用会员日。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 周几(1-7,对应周一到周日)。 + /// + public int Weekday { get; set; } = 2; + + /// + /// 会员日额外折扣(如 9 表示 9 折)。 + /// + public decimal ExtraDiscountRate { get; set; } = 9m; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs index 32df115..596d218 100644 --- a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs @@ -33,6 +33,21 @@ public sealed class MemberProfile : MultiTenantEntityBase /// public long? MemberTierId { get; set; } + /// + /// 储值余额。 + /// + public decimal StoredBalance { get; set; } + + /// + /// 储值实充余额。 + /// + public decimal StoredRechargeBalance { get; set; } + + /// + /// 储值赠金余额。 + /// + public decimal StoredGiftBalance { get; set; } + /// /// 会员状态。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfileTag.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfileTag.cs new file mode 100644 index 0000000..904a1ac --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfileTag.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员标签。 +/// +public sealed class MemberProfileTag : MultiTenantEntityBase +{ + /// + /// 会员标识。 + /// + public long MemberProfileId { get; set; } + + /// + /// 标签名。 + /// + public string TagName { get; set; } = string.Empty; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs index bd40724..686c43d 100644 --- a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs @@ -17,6 +17,41 @@ public sealed class MemberTier : MultiTenantEntityBase /// public int RequiredGrowth { get; set; } + /// + /// 图标键。 + /// + public string IconKey { get; set; } = "user"; + + /// + /// 主题色。 + /// + public string ColorHex { get; set; } = "#999999"; + + /// + /// 升级规则类型(none/amount/count/both)。 + /// + public string UpgradeRuleType { get; set; } = "none"; + + /// + /// 升级累计消费门槛。 + /// + public decimal? UpgradeAmountThreshold { get; set; } + + /// + /// 升级消费次数门槛。 + /// + public int? UpgradeOrderCountThreshold { get; set; } + + /// + /// 降级观察窗口天数。 + /// + public int DowngradeWindowDays { get; set; } = 90; + + /// + /// 是否默认等级。 + /// + public bool IsDefault { get; set; } + /// /// 等级权益(JSON)。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs new file mode 100644 index 0000000..ee4e272 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs @@ -0,0 +1,99 @@ +using TakeoutSaaS.Domain.Membership.Entities; + +namespace TakeoutSaaS.Domain.Membership.Repositories; + +/// +/// 会员聚合仓储契约。 +/// +public interface IMemberRepository +{ + /// + /// 查询租户下会员档案。 + /// + Task> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按手机号集合查询会员档案。 + /// + Task> GetProfilesByMobilesAsync( + long tenantId, + IReadOnlyCollection mobiles, + CancellationToken cancellationToken = default); + + /// + /// 按标识查询会员档案。 + /// + Task FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default); + + /// + /// 新增会员档案集合。 + /// + Task AddProfilesAsync(IEnumerable profiles, CancellationToken cancellationToken = default); + + /// + /// 更新会员档案。 + /// + Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default); + + /// + /// 查询租户下会员等级。 + /// + Task> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按标识查询会员等级。 + /// + Task FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default); + + /// + /// 新增会员等级。 + /// + Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default); + + /// + /// 更新会员等级。 + /// + Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default); + + /// + /// 删除会员等级。 + /// + Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default); + + /// + /// 查询租户会员日配置。 + /// + Task GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增会员日配置。 + /// + Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default); + + /// + /// 更新会员日配置。 + /// + Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default); + + /// + /// 查询会员标签集合。 + /// + Task> GetProfileTagsAsync( + long tenantId, + long memberProfileId, + CancellationToken cancellationToken = default); + + /// + /// 替换会员标签集合。 + /// + Task ReplaceProfileTagsAsync( + long tenantId, + long memberProfileId, + IReadOnlyCollection tags, + CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index ea54078..25850df 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Domain.Coupons.Repositories; using TakeoutSaaS.Domain.Deliveries.Repositories; using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Domain.Membership.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Domain.Merchants.Services; using TakeoutSaaS.Domain.Orders.Repositories; @@ -49,6 +50,7 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index db1e16d..4ec6715 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -390,6 +390,14 @@ public sealed class TakeoutAppDbContext( /// public DbSet MemberTiers => Set(); /// + /// 会员标签。 + /// + public DbSet MemberProfileTags => Set(); + /// + /// 会员日设置。 + /// + public DbSet MemberDaySettings => Set(); + /// /// 积分流水。 /// public DbSet MemberPointLedgers => Set(); @@ -557,6 +565,8 @@ public sealed class TakeoutAppDbContext( ConfigurePunchCardUsageRecord(modelBuilder.Entity()); ConfigureMemberProfile(modelBuilder.Entity()); ConfigureMemberTier(modelBuilder.Entity()); + ConfigureMemberProfileTag(modelBuilder.Entity()); + ConfigureMemberDaySetting(modelBuilder.Entity()); ConfigureMemberPointLedger(modelBuilder.Entity()); ConfigureChatSession(modelBuilder.Entity()); ConfigureChatMessage(modelBuilder.Entity()); @@ -1785,8 +1795,12 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.Mobile).HasMaxLength(32).IsRequired(); builder.Property(x => x.Nickname).HasMaxLength(64); builder.Property(x => x.AvatarUrl).HasMaxLength(256); + builder.Property(x => x.StoredBalance).HasPrecision(18, 2); + builder.Property(x => x.StoredRechargeBalance).HasPrecision(18, 2); + builder.Property(x => x.StoredGiftBalance).HasPrecision(18, 2); builder.Property(x => x.Status).HasConversion(); builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.MemberTierId }); } private static void ConfigureMemberTier(EntityTypeBuilder builder) @@ -1794,8 +1808,33 @@ public sealed class TakeoutAppDbContext( builder.ToTable("member_tiers"); builder.HasKey(x => x.Id); builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.IconKey).HasMaxLength(32).IsRequired(); + builder.Property(x => x.ColorHex).HasMaxLength(16).IsRequired(); + builder.Property(x => x.UpgradeRuleType).HasMaxLength(16).IsRequired(); + builder.Property(x => x.UpgradeAmountThreshold).HasPrecision(18, 2); builder.Property(x => x.BenefitsJson).HasColumnType("text"); builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.SortOrder }); + } + + private static void ConfigureMemberProfileTag(EntityTypeBuilder builder) + { + builder.ToTable("member_profile_tags"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MemberProfileId).IsRequired(); + builder.Property(x => x.TagName).HasMaxLength(32).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.MemberProfileId, x.TagName }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.MemberProfileId }); + } + + private static void ConfigureMemberDaySetting(EntityTypeBuilder builder) + { + builder.ToTable("member_day_settings"); + builder.HasKey(x => x.Id); + builder.Property(x => x.IsEnabled).IsRequired(); + builder.Property(x => x.Weekday).IsRequired(); + builder.Property(x => x.ExtraDiscountRate).HasPrecision(5, 2); + builder.HasIndex(x => x.TenantId).IsUnique(); } private static void ConfigureMemberPointLedger(EntityTypeBuilder builder) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs new file mode 100644 index 0000000..d9c6c70 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs @@ -0,0 +1,177 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 会员聚合 EF Core 仓储实现。 +/// +public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRepository +{ + /// + public async Task> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.MemberProfiles + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetProfilesByMobilesAsync( + long tenantId, + IReadOnlyCollection mobiles, + CancellationToken cancellationToken = default) + { + if (mobiles.Count == 0) + { + return []; + } + + return await context.MemberProfiles + .Where(x => x.TenantId == tenantId && mobiles.Contains(x.Mobile)) + .ToListAsync(cancellationToken); + } + + /// + public Task FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default) + { + return context.MemberProfiles + .Where(x => x.TenantId == tenantId && x.Id == memberId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task AddProfilesAsync(IEnumerable profiles, CancellationToken cancellationToken = default) + { + var profileList = profiles?.ToList() ?? []; + if (profileList.Count == 0) + { + return; + } + + await context.MemberProfiles.AddRangeAsync(profileList, cancellationToken); + } + + /// + public Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default) + { + context.MemberProfiles.Update(profile); + return Task.CompletedTask; + } + + /// + public async Task> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.MemberTiers + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } + + /// + public Task FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default) + { + return context.MemberTiers + .Where(x => x.TenantId == tenantId && x.Id == tierId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default) + { + return context.MemberTiers.AddAsync(tier, cancellationToken).AsTask(); + } + + /// + public Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default) + { + context.MemberTiers.Update(tier); + return Task.CompletedTask; + } + + /// + public Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default) + { + context.MemberTiers.Remove(tier); + return Task.CompletedTask; + } + + /// + public Task GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.MemberDaySettings + .Where(x => x.TenantId == tenantId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default) + { + return context.MemberDaySettings.AddAsync(setting, cancellationToken).AsTask(); + } + + /// + public Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default) + { + context.MemberDaySettings.Update(setting); + return Task.CompletedTask; + } + + /// + public async Task> GetProfileTagsAsync( + long tenantId, + long memberProfileId, + CancellationToken cancellationToken = default) + { + return await context.MemberProfileTags + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId) + .OrderBy(x => x.TagName) + .ToListAsync(cancellationToken); + } + + /// + public async Task ReplaceProfileTagsAsync( + long tenantId, + long memberProfileId, + IReadOnlyCollection tags, + CancellationToken cancellationToken = default) + { + var normalizedTags = (tags ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var existing = await context.MemberProfileTags + .Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId) + .ToListAsync(cancellationToken); + + context.MemberProfileTags.RemoveRange(existing); + + if (normalizedTags.Count == 0) + { + return; + } + + var entities = normalizedTags.Select(tag => new MemberProfileTag + { + MemberProfileId = memberProfileId, + TagName = tag + }); + + await context.MemberProfileTags.AddRangeAsync(entities, cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260303103000_AddMemberCenterModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260303103000_AddMemberCenterModule.cs new file mode 100644 index 0000000..8cc3692 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260303103000_AddMemberCenterModule.cs @@ -0,0 +1,225 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class AddMemberCenterModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ColorHex", + table: "member_tiers", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "#999999"); + + migrationBuilder.AddColumn( + name: "DowngradeWindowDays", + table: "member_tiers", + type: "integer", + nullable: false, + defaultValue: 90); + + migrationBuilder.AddColumn( + name: "IconKey", + table: "member_tiers", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: "user"); + + migrationBuilder.AddColumn( + name: "IsDefault", + table: "member_tiers", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "UpgradeAmountThreshold", + table: "member_tiers", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: true); + + migrationBuilder.AddColumn( + name: "UpgradeOrderCountThreshold", + table: "member_tiers", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "UpgradeRuleType", + table: "member_tiers", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "none"); + + migrationBuilder.AddColumn( + name: "StoredBalance", + table: "member_profiles", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "StoredGiftBalance", + table: "member_profiles", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "StoredRechargeBalance", + table: "member_profiles", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.CreateTable( + name: "member_day_settings", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + IsEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用会员日。"), + Weekday = table.Column(type: "integer", nullable: false, comment: "周几(1-7)。"), + ExtraDiscountRate = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false, comment: "会员日额外折扣。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_day_settings", x => x.Id); + }, + comment: "会员日设置。"); + + migrationBuilder.CreateTable( + name: "member_profile_tags", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MemberProfileId = table.Column(type: "bigint", nullable: false, comment: "会员标识。"), + TagName = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "标签名。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_profile_tags", x => x.Id); + }, + comment: "会员标签。"); + + migrationBuilder.CreateIndex( + name: "IX_member_day_settings_TenantId", + table: "member_day_settings", + column: "TenantId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_member_profiles_TenantId_MemberTierId", + table: "member_profiles", + columns: new[] { "TenantId", "MemberTierId" }); + + migrationBuilder.CreateIndex( + name: "IX_member_profile_tags_TenantId_MemberProfileId", + table: "member_profile_tags", + columns: new[] { "TenantId", "MemberProfileId" }); + + migrationBuilder.CreateIndex( + name: "IX_member_profile_tags_TenantId_MemberProfileId_TagName", + table: "member_profile_tags", + columns: new[] { "TenantId", "MemberProfileId", "TagName" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_member_tiers_TenantId_SortOrder", + table: "member_tiers", + columns: new[] { "TenantId", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "member_day_settings"); + + migrationBuilder.DropTable( + name: "member_profile_tags"); + + migrationBuilder.DropIndex( + name: "IX_member_profiles_TenantId_MemberTierId", + table: "member_profiles"); + + migrationBuilder.DropIndex( + name: "IX_member_tiers_TenantId_SortOrder", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "ColorHex", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "DowngradeWindowDays", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "IconKey", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "IsDefault", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "UpgradeAmountThreshold", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "UpgradeOrderCountThreshold", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "UpgradeRuleType", + table: "member_tiers"); + + migrationBuilder.DropColumn( + name: "StoredBalance", + table: "member_profiles"); + + migrationBuilder.DropColumn( + name: "StoredGiftBalance", + table: "member_profiles"); + + migrationBuilder.DropColumn( + name: "StoredRechargeBalance", + table: "member_profiles"); + } + } +}