feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s

This commit is contained in:
2026-03-03 20:38:31 +08:00
parent c2821202c7
commit d96ca4971a
40 changed files with 4846 additions and 1 deletions

View File

@@ -0,0 +1,346 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 会员列表筛选请求。
/// </summary>
public class MemberListFilterRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
}
/// <summary>
/// 会员列表分页请求。
/// </summary>
public sealed class MemberListRequest : MemberListFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 会员详情请求。
/// </summary>
public sealed class MemberDetailRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
}
/// <summary>
/// 保存会员标签请求。
/// </summary>
public sealed class SaveMemberTagsRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 标签集合。
/// </summary>
public List<string> Tags { get; set; } = [];
}
/// <summary>
/// 会员列表行响应。
/// </summary>
public sealed class MemberListItemResponse
{
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; set; } = string.Empty;
/// <summary>
/// 会员等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; set; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 最近消费时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; set; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// 是否沉睡会员。
/// </summary>
public bool IsDormant { get; set; }
}
/// <summary>
/// 会员列表响应。
/// </summary>
public sealed class MemberListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<MemberListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 会员列表统计响应。
/// </summary>
public sealed class MemberListStatsResponse
{
/// <summary>
/// 会员总数。
/// </summary>
public int TotalMembers { get; set; }
/// <summary>
/// 本月新增会员数。
/// </summary>
public int MonthlyNewMembers { get; set; }
/// <summary>
/// 活跃会员数。
/// </summary>
public int ActiveMembers { get; set; }
/// <summary>
/// 沉睡会员数。
/// </summary>
public int DormantMembers { get; set; }
}
/// <summary>
/// 会员最近订单响应。
/// </summary>
public sealed class MemberRecentOrderResponse
{
/// <summary>
/// 下单日期yyyy-MM-dd
/// </summary>
public string OrderedAt { get; set; } = string.Empty;
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 订单金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 订单状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
}
/// <summary>
/// 会员详情响应。
/// </summary>
public sealed class MemberDetailResponse
{
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; set; } = string.Empty;
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string JoinedAt { get; set; } = string.Empty;
/// <summary>
/// 会员等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; set; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 平均客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; set; }
/// <summary>
/// 储值实充余额。
/// </summary>
public decimal StoredRechargeBalance { get; set; }
/// <summary>
/// 储值赠金余额。
/// </summary>
public decimal StoredGiftBalance { get; set; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// 会员标签。
/// </summary>
public List<string> Tags { get; set; } = [];
/// <summary>
/// 最近订单。
/// </summary>
public List<MemberRecentOrderResponse> RecentOrders { get; set; } = [];
}
/// <summary>
/// 会员导出响应。
/// </summary>
public sealed class MemberExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,427 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 会员等级列表项响应。
/// </summary>
public sealed class MemberTierListItemResponse
{
/// <summary>
/// 等级标识。
/// </summary>
public string TierId { get; set; } = string.Empty;
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>
/// 升级条件文案。
/// </summary>
public string ConditionText { get; set; } = string.Empty;
/// <summary>
/// 权益摘要。
/// </summary>
public List<string> Perks { get; set; } = [];
/// <summary>
/// 等级会员数。
/// </summary>
public int MemberCount { get; set; }
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; set; }
}
/// <summary>
/// 等级详情查询请求。
/// </summary>
public sealed class MemberTierDetailRequest
{
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
}
/// <summary>
/// 等级规则响应。
/// </summary>
public sealed class MemberTierRuleResponse
{
/// <summary>
/// 升级规则类型。
/// </summary>
public string UpgradeRuleType { get; set; } = "none";
/// <summary>
/// 升级累计消费门槛。
/// </summary>
public decimal? UpgradeAmountThreshold { get; set; }
/// <summary>
/// 升级消费次数门槛。
/// </summary>
public int? UpgradeOrderCountThreshold { get; set; }
/// <summary>
/// 降级观察窗口天数。
/// </summary>
public int DowngradeWindowDays { get; set; }
}
/// <summary>
/// 折扣权益响应。
/// </summary>
public sealed class MemberTierDiscountBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 折扣值。
/// </summary>
public decimal? DiscountRate { get; set; }
}
/// <summary>
/// 积分倍率权益响应。
/// </summary>
public sealed class MemberTierPointMultiplierBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 倍率。
/// </summary>
public decimal? Multiplier { get; set; }
}
/// <summary>
/// 生日特权响应。
/// </summary>
public sealed class MemberTierBirthdayBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 是否双倍积分。
/// </summary>
public bool DoublePointsEnabled { get; set; }
/// <summary>
/// 券模板 ID。
/// </summary>
public List<string> CouponTemplateIds { get; set; } = [];
}
/// <summary>
/// 每月赠券响应。
/// </summary>
public sealed class MemberTierMonthlyCouponBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 每月发放日。
/// </summary>
public int GrantDay { get; set; }
/// <summary>
/// 券模板 ID。
/// </summary>
public List<string> CouponTemplateIds { get; set; } = [];
}
/// <summary>
/// 免配送费权益响应。
/// </summary>
public sealed class MemberTierFreeDeliveryBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 每月免配送费次数。
/// </summary>
public int MonthlyFreeTimes { get; set; }
}
/// <summary>
/// 等级权益响应。
/// </summary>
public sealed class MemberTierBenefitsResponse
{
/// <summary>
/// 折扣权益。
/// </summary>
public MemberTierDiscountBenefitResponse Discount { get; set; } = new();
/// <summary>
/// 积分倍率权益。
/// </summary>
public MemberTierPointMultiplierBenefitResponse PointMultiplier { get; set; } = new();
/// <summary>
/// 生日特权。
/// </summary>
public MemberTierBirthdayBenefitResponse Birthday { get; set; } = new();
/// <summary>
/// 每月赠券。
/// </summary>
public MemberTierMonthlyCouponBenefitResponse MonthlyCoupon { get; set; } = new();
/// <summary>
/// 免配送费权益。
/// </summary>
public MemberTierFreeDeliveryBenefitResponse FreeDelivery { get; set; } = new();
/// <summary>
/// 优先配送。
/// </summary>
public bool PriorityDeliveryEnabled { get; set; }
/// <summary>
/// 专属客服。
/// </summary>
public bool ExclusiveServiceEnabled { get; set; }
}
/// <summary>
/// 等级详情响应。
/// </summary>
public sealed class MemberTierDetailResponse
{
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleResponse Rule { get; set; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsResponse Benefits { get; set; } = new();
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; set; }
}
/// <summary>
/// 保存等级请求。
/// </summary>
public sealed class SaveMemberTierRequest
{
/// <summary>
/// 等级标识(为空时新增)。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = "user";
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = "#999999";
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleResponse Rule { get; set; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsResponse Benefits { get; set; } = new();
}
/// <summary>
/// 删除等级请求。
/// </summary>
public sealed class DeleteMemberTierRequest
{
/// <summary>
/// 等级标识。
/// </summary>
public string TierId { get; set; } = string.Empty;
}
/// <summary>
/// 会员日配置响应。
/// </summary>
public sealed class MemberDaySettingResponse
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; set; }
/// <summary>
/// 会员日额外折扣。
/// </summary>
public decimal ExtraDiscountRate { get; set; }
}
/// <summary>
/// 保存会员日配置请求。
/// </summary>
public sealed class SaveMemberDaySettingRequest
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; set; }
/// <summary>
/// 会员日额外折扣。
/// </summary>
public decimal ExtraDiscountRate { get; set; }
}
/// <summary>
/// 优惠券选择器请求。
/// </summary>
public sealed class MemberCouponPickerRequest
{
/// <summary>
/// 门店 ID可选
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 关键词。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 优惠券选择器项响应。
/// </summary>
public sealed class MemberCouponPickerItemResponse
{
/// <summary>
/// 券模板标识。
/// </summary>
public string CouponTemplateId { get; set; } = string.Empty;
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 券类型。
/// </summary>
public string CouponType { get; set; } = string.Empty;
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 最低消费门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 展示文案。
/// </summary>
public string DisplayText { get; set; } = string.Empty;
}

View File

@@ -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;
/// <summary>
/// 会员管理列表与详情。
/// </summary>
[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";
/// <summary>
/// 获取会员列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberListResultResponse>> 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<MemberListResultResponse>.Ok(new MemberListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 获取会员列表统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberListStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberListStatsResponse>> 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<MemberListStatsResponse>.Ok(new MemberListStatsResponse
{
TotalMembers = result.TotalMembers,
MonthlyNewMembers = result.MonthlyNewMembers,
ActiveMembers = result.ActiveMembers,
DormantMembers = result.DormantMembers
});
}
/// <summary>
/// 获取会员详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDetailResponse>> 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<MemberDetailResponse>.Error(ErrorCodes.NotFound, "会员不存在");
}
return ApiResponse<MemberDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存会员标签。
/// </summary>
[HttpPost("tags")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> 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<object>.Ok(null);
}
/// <summary>
/// 导出会员列表 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberExportResponse>> 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<MemberExportResponse>.Ok(new MemberExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<IReadOnlyCollection<long>> 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()
};
}
}

View File

@@ -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;
/// <summary>
/// 会员等级体系与会员日配置。
/// </summary>
[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";
/// <summary>
/// 获取会员等级列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<List<MemberTierListItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<MemberTierListItemResponse>>> List(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberTierListQuery(), cancellationToken);
return ApiResponse<List<MemberTierListItemResponse>>.Ok(result.Select(MapTierListItem).ToList());
}
/// <summary>
/// 获取会员等级详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberTierDetailResponse>> Detail(
[FromQuery] MemberTierDetailRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberTierDetailQuery
{
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
}, cancellationToken);
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
}
/// <summary>
/// 保存会员等级。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberTierDetailResponse>> 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<MemberTierDetailResponse>.Ok(MapTierDetail(result));
}
/// <summary>
/// 删除会员等级。
/// </summary>
[HttpPost("delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteMemberTierRequest request,
CancellationToken cancellationToken)
{
await mediator.Send(new DeleteMemberTierCommand
{
TierId = StoreApiHelpers.ParseRequiredSnowflake(request.TierId, nameof(request.TierId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取会员日配置。
/// </summary>
[HttpGet("day-setting")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDaySettingResponse>> GetDaySetting(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberDaySettingQuery(), cancellationToken);
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
{
IsEnabled = result.IsEnabled,
Weekday = result.Weekday,
ExtraDiscountRate = result.ExtraDiscountRate
});
}
/// <summary>
/// 保存会员日配置。
/// </summary>
[HttpPost("day-setting")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDaySettingResponse>> 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<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
{
IsEnabled = result.IsEnabled,
Weekday = result.Weekday,
ExtraDiscountRate = result.ExtraDiscountRate
});
}
/// <summary>
/// 查询可选优惠券列表。
/// </summary>
[HttpGet("coupon-picker")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<List<MemberCouponPickerItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<MemberCouponPickerItemResponse>>> 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<List<MemberCouponPickerItemResponse>>.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<IReadOnlyCollection<long>> 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
};
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Members.Commands;
/// <summary>
/// 删除会员等级命令。
/// </summary>
public sealed class DeleteMemberTierCommand : IRequest
{
/// <summary>
/// 等级标识。
/// </summary>
public long TierId { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Commands;
/// <summary>
/// 保存会员日配置命令。
/// </summary>
public sealed class SaveMemberDaySettingCommand : IRequest<MemberDaySettingDto>
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; init; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; init; }
/// <summary>
/// 会员日额外折扣(如 9 表示 9 折)。
/// </summary>
public decimal ExtraDiscountRate { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Members.Commands;
/// <summary>
/// 保存会员标签命令。
/// </summary>
public sealed class SaveMemberTagsCommand : IRequest
{
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 标签集合。
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Commands;
/// <summary>
/// 保存会员等级命令。
/// </summary>
public sealed class SaveMemberTierCommand : IRequest<MemberTierDetailDto>
{
/// <summary>
/// 等级标识(为空时新增)。
/// </summary>
public long? TierId { get; init; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; init; } = "user";
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; init; } = "#999999";
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; init; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleDto Rule { get; init; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsDto Benefits { get; init; } = new();
}

View File

@@ -0,0 +1,567 @@
namespace TakeoutSaaS.Application.App.Members.Dto;
/// <summary>
/// 会员列表行 DTO。
/// </summary>
public sealed class MemberListItemDto
{
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; init; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; init; } = string.Empty;
/// <summary>
/// 会员等级标识。
/// </summary>
public long? TierId { get; init; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; init; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 最近消费时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; init; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; init; }
/// <summary>
/// 是否沉睡会员(用于弱化展示)。
/// </summary>
public bool IsDormant { get; init; }
}
/// <summary>
/// 会员列表统计 DTO。
/// </summary>
public sealed class MemberListStatsDto
{
/// <summary>
/// 会员总数。
/// </summary>
public int TotalMembers { get; init; }
/// <summary>
/// 本月新增会员数。
/// </summary>
public int MonthlyNewMembers { get; init; }
/// <summary>
/// 活跃会员数(近 30 天有消费)。
/// </summary>
public int ActiveMembers { get; init; }
/// <summary>
/// 沉睡会员数(超过 60 天未消费)。
/// </summary>
public int DormantMembers { get; init; }
}
/// <summary>
/// 会员最近订单 DTO。
/// </summary>
public sealed class MemberRecentOrderDto
{
/// <summary>
/// 日期。
/// </summary>
public DateTime OrderedAt { get; init; }
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 订单金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 订单状态文案。
/// </summary>
public string StatusText { get; init; } = string.Empty;
}
/// <summary>
/// 会员详情 DTO。
/// </summary>
public sealed class MemberDetailDto
{
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; init; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; init; } = string.Empty;
/// <summary>
/// 注册时间。
/// </summary>
public DateTime JoinedAt { get; init; }
/// <summary>
/// 会员等级标识。
/// </summary>
public long? TierId { get; init; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; init; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 平均客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; init; }
/// <summary>
/// 储值实充余额。
/// </summary>
public decimal StoredRechargeBalance { get; init; }
/// <summary>
/// 储值赠金余额。
/// </summary>
public decimal StoredGiftBalance { get; init; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; init; }
/// <summary>
/// 会员标签。
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
/// <summary>
/// 最近订单。
/// </summary>
public IReadOnlyList<MemberRecentOrderDto> RecentOrders { get; init; } = [];
}
/// <summary>
/// 会员列表导出 DTO。
/// </summary>
public sealed class MemberExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; init; }
}
/// <summary>
/// 会员等级列表项 DTO。
/// </summary>
public sealed class MemberTierListItemDto
{
/// <summary>
/// 等级标识。
/// </summary>
public long TierId { get; init; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; init; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; init; } = string.Empty;
/// <summary>
/// 升级条件文案。
/// </summary>
public string ConditionText { get; init; } = string.Empty;
/// <summary>
/// 权益摘要。
/// </summary>
public IReadOnlyList<string> Perks { get; init; } = [];
/// <summary>
/// 等级会员数。
/// </summary>
public int MemberCount { get; init; }
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; init; }
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; init; }
}
/// <summary>
/// 会员等级规则 DTO。
/// </summary>
public sealed class MemberTierRuleDto
{
/// <summary>
/// 升级规则类型none/amount/count/both
/// </summary>
public string UpgradeRuleType { get; init; } = "none";
/// <summary>
/// 升级累计消费门槛。
/// </summary>
public decimal? UpgradeAmountThreshold { get; init; }
/// <summary>
/// 升级消费次数门槛。
/// </summary>
public int? UpgradeOrderCountThreshold { get; init; }
/// <summary>
/// 降级观察窗口天数。
/// </summary>
public int DowngradeWindowDays { get; init; } = 90;
}
/// <summary>
/// 会员等级权益 DTO。
/// </summary>
public sealed class MemberTierBenefitsDto
{
/// <summary>
/// 折扣权益。
/// </summary>
public MemberTierDiscountBenefitDto Discount { get; init; } = new();
/// <summary>
/// 积分倍率权益。
/// </summary>
public MemberTierPointMultiplierBenefitDto PointMultiplier { get; init; } = new();
/// <summary>
/// 生日特权。
/// </summary>
public MemberTierBirthdayBenefitDto Birthday { get; init; } = new();
/// <summary>
/// 每月赠券。
/// </summary>
public MemberTierMonthlyCouponBenefitDto MonthlyCoupon { get; init; } = new();
/// <summary>
/// 免配送费权益。
/// </summary>
public MemberTierFreeDeliveryBenefitDto FreeDelivery { get; init; } = new();
/// <summary>
/// 优先配送。
/// </summary>
public bool PriorityDeliveryEnabled { get; init; }
/// <summary>
/// 专属客服。
/// </summary>
public bool ExclusiveServiceEnabled { get; init; }
}
/// <summary>
/// 折扣权益 DTO。
/// </summary>
public sealed class MemberTierDiscountBenefitDto
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 折扣值(如 9.5)。
/// </summary>
public decimal? DiscountRate { get; init; }
}
/// <summary>
/// 积分倍率权益 DTO。
/// </summary>
public sealed class MemberTierPointMultiplierBenefitDto
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 倍率(如 1.5)。
/// </summary>
public decimal? Multiplier { get; init; }
}
/// <summary>
/// 生日特权 DTO。
/// </summary>
public sealed class MemberTierBirthdayBenefitDto
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 是否双倍积分。
/// </summary>
public bool DoublePointsEnabled { get; init; }
/// <summary>
/// 生日赠券模板 ID。
/// </summary>
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
}
/// <summary>
/// 每月赠券 DTO。
/// </summary>
public sealed class MemberTierMonthlyCouponBenefitDto
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 每月发放日1-28
/// </summary>
public int GrantDay { get; init; } = 1;
/// <summary>
/// 每月赠券模板 ID。
/// </summary>
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
}
/// <summary>
/// 免配送费权益 DTO。
/// </summary>
public sealed class MemberTierFreeDeliveryBenefitDto
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 每月免配送费次数。
/// </summary>
public int MonthlyFreeTimes { get; init; }
}
/// <summary>
/// 会员等级详情 DTO。
/// </summary>
public sealed class MemberTierDetailDto
{
/// <summary>
/// 等级标识。
/// </summary>
public long? TierId { get; init; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; init; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; init; } = string.Empty;
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; init; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleDto Rule { get; init; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsDto Benefits { get; init; } = new();
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; init; }
}
/// <summary>
/// 会员日配置 DTO。
/// </summary>
public sealed class MemberDaySettingDto
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; init; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; init; }
/// <summary>
/// 会员日额外折扣(如 9 表示 9 折)。
/// </summary>
public decimal ExtraDiscountRate { get; init; }
}
/// <summary>
/// 优惠券选择项 DTO。
/// </summary>
public sealed class MemberCouponPickerItemDto
{
/// <summary>
/// 券模板标识。
/// </summary>
public long CouponTemplateId { get; init; }
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 券类型。
/// </summary>
public string CouponType { get; init; } = string.Empty;
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; init; }
/// <summary>
/// 最低消费门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 展示文案。
/// </summary>
public string DisplayText { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 删除会员等级命令处理器。
/// </summary>
public sealed class DeleteMemberTierCommandHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteMemberTierCommand>
{
/// <inheritdoc />
public async Task Handle(DeleteMemberTierCommand request, CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
var tenantId = tenantProvider.GetCurrentTenantId();
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId)
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
if (tier.IsDefault)
{
throw new BusinessException(ErrorCodes.BadRequest, "默认等级不允许删除");
}
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
if (profiles.Any(item => item.MemberTierId == request.TierId))
{
throw new BusinessException(ErrorCodes.BadRequest, "当前等级下存在会员,无法删除");
}
await memberRepository.DeleteTierAsync(tier, cancellationToken);
await memberRepository.SaveChangesAsync(cancellationToken);
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
if (!latestTiers.Any(item => item.IsDefault) && latestTiers.Count > 0)
{
var fallback = latestTiers
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.First();
fallback.IsDefault = true;
await memberRepository.UpdateTierAsync(fallback, cancellationToken);
await memberRepository.SaveChangesAsync(cancellationToken);
}
}
}

View File

@@ -0,0 +1,87 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员列表 CSV 导出查询处理器。
/// </summary>
public sealed class ExportMemberCsvQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportMemberCsvQuery, MemberExportDto>
{
/// <inheritdoc />
public async Task<MemberExportDto> Handle(
ExportMemberCsvQuery request,
CancellationToken cancellationToken)
{
if (request.VisibleStoreIds.Count == 0)
{
return new MemberExportDto
{
FileName = BuildFileName(),
FileContentBase64 = string.Empty,
TotalCount = 0
};
}
var context = await MemberCenterSupport.LoadMemberContextAsync(
memberRepository,
orderRepository,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var filtered = MemberCenterSupport.ApplyFilters(
context.Aggregates,
request.Keyword,
request.TierId)
.OrderByDescending(item => item.Metrics.LastOrderAt)
.ThenByDescending(item => item.MemberId)
.ToList();
var rows = filtered
.Select(item => new MemberListItemDto
{
MemberId = item.MemberId,
Name = item.Name,
AvatarText = item.AvatarText,
AvatarColor = item.AvatarColor,
MobileMasked = item.MobileMasked,
TierId = item.Tier?.Id,
TierName = item.Tier?.Name ?? string.Empty,
TierColorHex = item.Tier?.ColorHex ?? "#999999",
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
OrderCount = item.Metrics.TotalOrderCount,
LastOrderAt = item.Metrics.LastOrderAt,
StoredBalance = item.Profile.StoredBalance,
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
IsDormant = item.IsDormant
})
.ToList();
var csv = MemberCenterSupport.BuildCsv(rows);
var payload = Encoding.UTF8.GetPreamble()
.Concat(Encoding.UTF8.GetBytes(csv))
.ToArray();
return new MemberExportDto
{
FileName = BuildFileName(),
FileContentBase64 = Convert.ToBase64String(payload),
TotalCount = rows.Count
};
}
private static string BuildFileName()
{
return $"member-list-{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员日配置查询处理器。
/// </summary>
public sealed class GetMemberDaySettingQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMemberDaySettingQuery, MemberDaySettingDto>
{
/// <inheritdoc />
public async Task<MemberDaySettingDto> Handle(GetMemberDaySettingQuery request, CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
var tenantId = tenantProvider.GetCurrentTenantId();
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
if (setting is null)
{
return new MemberDaySettingDto
{
IsEnabled = true,
Weekday = 2,
ExtraDiscountRate = 9m
};
}
return MemberCenterSupport.ToMemberDaySettingDto(setting);
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员详情查询处理器。
/// </summary>
public sealed class GetMemberDetailQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMemberDetailQuery, MemberDetailDto?>
{
/// <inheritdoc />
public async Task<MemberDetailDto?> Handle(
GetMemberDetailQuery request,
CancellationToken cancellationToken)
{
if (request.VisibleStoreIds.Count == 0)
{
return null;
}
var context = await MemberCenterSupport.LoadMemberContextAsync(
memberRepository,
orderRepository,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var aggregate = context.Aggregates.FirstOrDefault(item => item.MemberId == request.MemberId);
if (aggregate is null)
{
return null;
}
var tenantId = tenantProvider.GetCurrentTenantId();
var tags = await memberRepository.GetProfileTagsAsync(tenantId, aggregate.MemberId, cancellationToken);
var recentOrders = aggregate.Metrics.Orders
.OrderByDescending(item => item.OrderedAt)
.ThenByDescending(item => item.OrderId)
.Take(3)
.Select(item => new MemberRecentOrderDto
{
OrderedAt = item.OrderedAt,
OrderNo = item.OrderNo,
Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero),
StatusText = MemberCenterSupport.ResolveOrderStatusText(item.Status)
})
.ToList();
var averageAmount = aggregate.Metrics.TotalOrderCount <= 0
? 0
: decimal.Round(aggregate.Metrics.TotalAmount / aggregate.Metrics.TotalOrderCount, 2, MidpointRounding.AwayFromZero);
return new MemberDetailDto
{
MemberId = aggregate.MemberId,
Name = aggregate.Name,
AvatarText = aggregate.AvatarText,
AvatarColor = aggregate.AvatarColor,
MobileMasked = aggregate.MobileMasked,
JoinedAt = aggregate.JoinedAt,
TierId = aggregate.Tier?.Id,
TierName = aggregate.Tier?.Name ?? string.Empty,
TierColorHex = aggregate.Tier?.ColorHex ?? "#999999",
TotalAmount = decimal.Round(aggregate.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
OrderCount = aggregate.Metrics.TotalOrderCount,
AverageAmount = averageAmount,
StoredBalance = aggregate.Profile.StoredBalance,
StoredRechargeBalance = aggregate.Profile.StoredRechargeBalance,
StoredGiftBalance = aggregate.Profile.StoredGiftBalance,
PointsBalance = Math.Max(0, aggregate.Profile.PointsBalance),
Tags = tags.Select(item => item.TagName).ToList(),
RecentOrders = recentOrders
};
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员列表统计查询处理器。
/// </summary>
public sealed class GetMemberListStatsQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMemberListStatsQuery, MemberListStatsDto>
{
/// <inheritdoc />
public async Task<MemberListStatsDto> Handle(
GetMemberListStatsQuery request,
CancellationToken cancellationToken)
{
if (request.VisibleStoreIds.Count == 0)
{
return new MemberListStatsDto();
}
var context = await MemberCenterSupport.LoadMemberContextAsync(
memberRepository,
orderRepository,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var filtered = MemberCenterSupport.ApplyFilters(
context.Aggregates,
request.Keyword,
request.TierId);
var nowUtc = DateTime.UtcNow;
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return new MemberListStatsDto
{
TotalMembers = filtered.Count,
MonthlyNewMembers = filtered.Count(item => item.JoinedAt >= monthStart),
ActiveMembers = filtered.Count(item => item.Metrics.LastOrderAt >= nowUtc.AddDays(-30)),
DormantMembers = filtered.Count(item => item.Metrics.LastOrderAt < nowUtc.AddDays(-60))
};
}
}

View File

@@ -0,0 +1,70 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员等级详情查询处理器。
/// </summary>
public sealed class GetMemberTierDetailQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMemberTierDetailQuery, MemberTierDetailDto>
{
/// <inheritdoc />
public async Task<MemberTierDetailDto> Handle(
GetMemberTierDetailQuery request,
CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
var tenantId = tenantProvider.GetCurrentTenantId();
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
if (!request.TierId.HasValue)
{
var nextSort = tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1;
return new MemberTierDetailDto
{
TierId = null,
SortOrder = nextSort,
Name = string.Empty,
IconKey = "user",
ColorHex = "#999999",
IsDefault = false,
Rule = new MemberTierRuleDto
{
UpgradeRuleType = "amount",
UpgradeAmountThreshold = 500m,
UpgradeOrderCountThreshold = null,
DowngradeWindowDays = 90
},
Benefits = new MemberTierBenefitsDto(),
CanDelete = false
};
}
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value);
if (tier is null)
{
throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
}
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
var assignedCount = profiles.Count(item => item.MemberTierId == tier.Id);
var canDelete = !tier.IsDefault && assignedCount <= 0 && tiers.Count > 1;
return MemberCenterSupport.ToTierDetailDto(tier, canDelete);
}
}

View File

@@ -0,0 +1,71 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员等级列表查询处理器。
/// </summary>
public sealed class GetMemberTierListQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMemberTierListQuery, IReadOnlyList<MemberTierListItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MemberTierListItemDto>> Handle(
GetMemberTierListQuery request,
CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
var tenantId = tenantProvider.GetCurrentTenantId();
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
if (tiers.Count == 0)
{
return [];
}
var memberCountMap = profiles
.Where(item => item.MemberTierId.HasValue)
.GroupBy(item => item.MemberTierId!.Value)
.ToDictionary(group => group.Key, group => group.Count());
var result = tiers
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.Select(tier =>
{
memberCountMap.TryGetValue(tier.Id, out var memberCount);
var benefits = MemberCenterSupport.DeserializeBenefits(tier.BenefitsJson);
var canDelete = !tier.IsDefault && memberCount <= 0 && tiers.Count > 1;
return new MemberTierListItemDto
{
TierId = tier.Id,
SortOrder = tier.SortOrder,
Name = tier.Name,
IconKey = tier.IconKey,
ColorHex = tier.ColorHex,
ConditionText = MemberCenterSupport.BuildConditionText(tier),
Perks = MemberCenterSupport.BuildPerks(tier, benefits),
MemberCount = memberCount,
IsDefault = tier.IsDefault,
CanDelete = canDelete
};
})
.ToList();
return result;
}
}

View File

@@ -0,0 +1,67 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 保存会员日配置命令处理器。
/// </summary>
public sealed class SaveMemberDaySettingCommandHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveMemberDaySettingCommand, MemberDaySettingDto>
{
/// <inheritdoc />
public async Task<MemberDaySettingDto> Handle(
SaveMemberDaySettingCommand request,
CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
if (request.Weekday is < 1 or > 7)
{
throw new BusinessException(ErrorCodes.BadRequest, "weekday 必须在 1-7 之间");
}
var normalizedDiscount = decimal.Round(request.ExtraDiscountRate, 2, MidpointRounding.AwayFromZero);
if (normalizedDiscount <= 0 || normalizedDiscount > 10)
{
throw new BusinessException(ErrorCodes.BadRequest, "extraDiscountRate 必须大于 0 且不超过 10");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
if (setting is null)
{
setting = new MemberDaySetting
{
IsEnabled = request.IsEnabled,
Weekday = request.Weekday,
ExtraDiscountRate = normalizedDiscount
};
await memberRepository.AddMemberDaySettingAsync(setting, cancellationToken);
}
else
{
setting.IsEnabled = request.IsEnabled;
setting.Weekday = request.Weekday;
setting.ExtraDiscountRate = normalizedDiscount;
await memberRepository.UpdateMemberDaySettingAsync(setting, cancellationToken);
}
await memberRepository.SaveChangesAsync(cancellationToken);
return MemberCenterSupport.ToMemberDaySettingDto(setting);
}
}

View File

@@ -0,0 +1,47 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 保存会员标签命令处理器。
/// </summary>
public sealed class SaveMemberTagsCommandHandler(
IMemberRepository memberRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveMemberTagsCommand>
{
/// <inheritdoc />
public async Task Handle(SaveMemberTagsCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var profile = await memberRepository.FindProfileByIdAsync(tenantId, request.MemberId, cancellationToken);
if (profile is null)
{
throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
}
var tags = (request.Tags ?? [])
.Select(item => (item ?? string.Empty).Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (tags.Count > 20)
{
throw new BusinessException(ErrorCodes.BadRequest, "标签数量不能超过 20 个");
}
if (tags.Any(item => item.Length > 32))
{
throw new BusinessException(ErrorCodes.BadRequest, "标签长度不能超过 32 个字符");
}
await memberRepository.ReplaceProfileTagsAsync(tenantId, request.MemberId, tags, cancellationToken);
await memberRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,202 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 保存会员等级命令处理器。
/// </summary>
public sealed class SaveMemberTierCommandHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveMemberTierCommand, MemberTierDetailDto>
{
/// <inheritdoc />
public async Task<MemberTierDetailDto> Handle(
SaveMemberTierCommand request,
CancellationToken cancellationToken)
{
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
memberRepository,
orderRepository,
tenantProvider,
cancellationToken);
var tenantId = tenantProvider.GetCurrentTenantId();
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
var normalizedName = (request.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能为空");
}
if (normalizedName.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能超过 64 个字符");
}
var normalizedIconKey = (request.IconKey ?? "user").Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedIconKey) || normalizedIconKey.Length > 32)
{
throw new BusinessException(ErrorCodes.BadRequest, "等级图标不合法");
}
var normalizedColorHex = NormalizeColorHex(request.ColorHex);
var normalizedRuleType = MemberCenterSupport.NormalizeRuleType(request.Rule.UpgradeRuleType);
var amountThreshold = request.Rule.UpgradeAmountThreshold;
var countThreshold = request.Rule.UpgradeOrderCountThreshold;
if (normalizedRuleType is "amount" or "both")
{
if (!amountThreshold.HasValue || amountThreshold.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "升级金额门槛必须大于 0");
}
}
if (normalizedRuleType is "count" or "both")
{
if (!countThreshold.HasValue || countThreshold.Value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "升级消费次数门槛必须大于 0");
}
}
var normalizedDowngradeWindowDays = Math.Clamp(request.Rule.DowngradeWindowDays <= 0 ? 90 : request.Rule.DowngradeWindowDays, 30, 365);
var normalizedSortOrder = request.SortOrder > 0
? request.SortOrder
: (request.TierId.HasValue
? tiers.FirstOrDefault(item => item.Id == request.TierId.Value)?.SortOrder ?? 1
: (tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1));
var normalizedBenefits = MemberCenterSupport.NormalizeBenefits(request.Benefits);
var duplicate = tiers.Any(item =>
item.Id != (request.TierId ?? 0) &&
string.Equals(item.Name, normalizedName, StringComparison.OrdinalIgnoreCase));
if (duplicate)
{
throw new BusinessException(ErrorCodes.BadRequest, "等级名称已存在");
}
MemberTier targetTier;
if (request.TierId.HasValue)
{
targetTier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value)
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
targetTier.Name = normalizedName;
targetTier.IconKey = normalizedIconKey;
targetTier.ColorHex = normalizedColorHex;
targetTier.UpgradeRuleType = normalizedRuleType;
targetTier.UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
: null;
targetTier.UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
? Math.Max(0, countThreshold ?? 0)
: null;
targetTier.DowngradeWindowDays = normalizedDowngradeWindowDays;
targetTier.IsDefault = request.IsDefault;
targetTier.SortOrder = normalizedSortOrder;
targetTier.RequiredGrowth = ResolveRequiredGrowth(targetTier.UpgradeAmountThreshold, targetTier.UpgradeOrderCountThreshold);
targetTier.BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits);
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
}
else
{
targetTier = new MemberTier
{
Name = normalizedName,
IconKey = normalizedIconKey,
ColorHex = normalizedColorHex,
UpgradeRuleType = normalizedRuleType,
UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
: null,
UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
? Math.Max(0, countThreshold ?? 0)
: null,
DowngradeWindowDays = normalizedDowngradeWindowDays,
IsDefault = request.IsDefault,
SortOrder = normalizedSortOrder,
RequiredGrowth = ResolveRequiredGrowth(amountThreshold, countThreshold),
BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits)
};
await memberRepository.AddTierAsync(targetTier, cancellationToken);
tiers = tiers.Append(targetTier).ToList();
}
if (request.IsDefault)
{
foreach (var tier in tiers.Where(item => item.Id != targetTier.Id && item.IsDefault))
{
tier.IsDefault = false;
await memberRepository.UpdateTierAsync(tier, cancellationToken);
}
}
else
{
var hasDefault = tiers.Any(item => (item.Id == targetTier.Id ? targetTier.IsDefault : item.IsDefault));
if (!hasDefault)
{
var fallbackTier = tiers
.Where(item => item.Id != targetTier.Id)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.FirstOrDefault();
if (fallbackTier is not null)
{
fallbackTier.IsDefault = true;
await memberRepository.UpdateTierAsync(fallbackTier, cancellationToken);
}
else
{
targetTier.IsDefault = true;
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
}
}
}
await memberRepository.SaveChangesAsync(cancellationToken);
var profileCount = (await memberRepository.GetProfilesAsync(tenantId, cancellationToken)).Count(item => item.MemberTierId == targetTier.Id);
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
var canDelete = !targetTier.IsDefault && profileCount <= 0 && latestTiers.Count > 1;
var latestTier = latestTiers.First(item => item.Id == targetTier.Id);
return MemberCenterSupport.ToTierDetailDto(latestTier, canDelete);
}
private static string NormalizeColorHex(string? value)
{
var candidate = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(candidate))
{
return "#999999";
}
if (!candidate.StartsWith('#'))
{
candidate = $"#{candidate}";
}
if (candidate.Length is not (4 or 7 or 9))
{
throw new BusinessException(ErrorCodes.BadRequest, "等级颜色不合法");
}
return candidate.ToLowerInvariant();
}
private static int ResolveRequiredGrowth(decimal? amountThreshold, int? countThreshold)
{
var amountGrowth = amountThreshold.HasValue ? (int)Math.Clamp(decimal.Round(amountThreshold.Value, 0, MidpointRounding.AwayFromZero), 0m, int.MaxValue) : 0;
var countGrowth = Math.Max(0, countThreshold ?? 0) * 100;
return Math.Max(amountGrowth, countGrowth);
}
}

View File

@@ -0,0 +1,55 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 优惠券选择器查询处理器。
/// </summary>
public sealed class SearchMemberCouponPickerQueryHandler(
ICouponRepository couponRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchMemberCouponPickerQuery, IReadOnlyList<MemberCouponPickerItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MemberCouponPickerItemDto>> Handle(
SearchMemberCouponPickerQuery request,
CancellationToken cancellationToken)
{
if (request.VisibleStoreIds.Count == 0)
{
return [];
}
var tenantId = tenantProvider.GetCurrentTenantId();
var keyword = (request.Keyword ?? string.Empty).Trim();
var nowUtc = DateTime.UtcNow;
var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
var filtered = templates
.Where(template => MemberCenterSupport.IsCouponVisibleToStores(template, request.VisibleStoreIds))
.Where(template => MemberCenterSupport.IsCouponActive(template, nowUtc))
.Where(template =>
string.IsNullOrWhiteSpace(keyword) ||
template.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(template => template.UpdatedAt ?? template.CreatedAt)
.ThenByDescending(template => template.Id)
.Take(200)
.Select(template => new MemberCouponPickerItemDto
{
CouponTemplateId = template.Id,
Name = template.Name,
CouponType = MemberCenterSupport.ResolveCouponTypeText(template.CouponType),
Value = decimal.Round(template.Value, 2, MidpointRounding.AwayFromZero),
MinimumSpend = template.MinimumSpend,
DisplayText = MemberCenterSupport.BuildCouponDisplayText(template)
})
.ToList();
return filtered;
}
}

View File

@@ -0,0 +1,73 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.Handlers;
/// <summary>
/// 会员列表查询处理器。
/// </summary>
public sealed class SearchMemberListQueryHandler(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchMemberListQuery, PagedResult<MemberListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<MemberListItemDto>> Handle(
SearchMemberListQuery request,
CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
if (request.VisibleStoreIds.Count == 0)
{
return new PagedResult<MemberListItemDto>([], page, pageSize, 0);
}
var context = await MemberCenterSupport.LoadMemberContextAsync(
memberRepository,
orderRepository,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var filtered = MemberCenterSupport.ApplyFilters(
context.Aggregates,
request.Keyword,
request.TierId)
.OrderByDescending(item => item.Metrics.LastOrderAt)
.ThenByDescending(item => item.MemberId)
.ToList();
var totalCount = filtered.Count;
var items = filtered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(item => new MemberListItemDto
{
MemberId = item.MemberId,
Name = item.Name,
AvatarText = item.AvatarText,
AvatarColor = item.AvatarColor,
MobileMasked = item.MobileMasked,
TierId = item.Tier?.Id,
TierName = item.Tier?.Name ?? string.Empty,
TierColorHex = item.Tier?.ColorHex ?? "#999999",
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
OrderCount = item.Metrics.TotalOrderCount,
LastOrderAt = item.Metrics.LastOrderAt,
StoredBalance = item.Profile.StoredBalance,
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
IsDormant = item.IsDormant
})
.ToList();
return new PagedResult<MemberListItemDto>(items, page, pageSize, totalCount);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员列表 CSV 导出查询。
/// </summary>
public sealed class ExportMemberCsvQuery : IRequest<MemberExportDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 等级标识。
/// </summary>
public long? TierId { get; init; }
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员日配置查询。
/// </summary>
public sealed class GetMemberDaySettingQuery : IRequest<MemberDaySettingDto>
{
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员详情查询。
/// </summary>
public sealed class GetMemberDetailQuery : IRequest<MemberDetailDto?>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员列表统计查询。
/// </summary>
public sealed class GetMemberListStatsQuery : IRequest<MemberListStatsDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 等级标识。
/// </summary>
public long? TierId { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员等级详情查询。
/// </summary>
public sealed class GetMemberTierDetailQuery : IRequest<MemberTierDetailDto>
{
/// <summary>
/// 等级标识(为空时返回新增默认模板)。
/// </summary>
public long? TierId { get; init; }
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员等级列表查询。
/// </summary>
public sealed class GetMemberTierListQuery : IRequest<IReadOnlyList<MemberTierListItemDto>>
{
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 优惠券选择器查询。
/// </summary>
public sealed class SearchMemberCouponPickerQuery : IRequest<IReadOnlyList<MemberCouponPickerItemDto>>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 搜索关键词。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Members.Queries;
/// <summary>
/// 会员列表查询。
/// </summary>
public sealed class SearchMemberListQuery : IRequest<PagedResult<MemberListItemDto>>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 等级标识。
/// </summary>
public long? TierId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,24 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员日配置。
/// </summary>
public sealed class MemberDaySetting : MultiTenantEntityBase
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; set; } = 2;
/// <summary>
/// 会员日额外折扣(如 9 表示 9 折)。
/// </summary>
public decimal ExtraDiscountRate { get; set; } = 9m;
}

View File

@@ -33,6 +33,21 @@ public sealed class MemberProfile : MultiTenantEntityBase
/// </summary>
public long? MemberTierId { get; set; }
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; set; }
/// <summary>
/// 储值实充余额。
/// </summary>
public decimal StoredRechargeBalance { get; set; }
/// <summary>
/// 储值赠金余额。
/// </summary>
public decimal StoredGiftBalance { get; set; }
/// <summary>
/// 会员状态。
/// </summary>

View File

@@ -0,0 +1,19 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员标签。
/// </summary>
public sealed class MemberProfileTag : MultiTenantEntityBase
{
/// <summary>
/// 会员标识。
/// </summary>
public long MemberProfileId { get; set; }
/// <summary>
/// 标签名。
/// </summary>
public string TagName { get; set; } = string.Empty;
}

View File

@@ -17,6 +17,41 @@ public sealed class MemberTier : MultiTenantEntityBase
/// </summary>
public int RequiredGrowth { get; set; }
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = "user";
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = "#999999";
/// <summary>
/// 升级规则类型none/amount/count/both
/// </summary>
public string UpgradeRuleType { get; set; } = "none";
/// <summary>
/// 升级累计消费门槛。
/// </summary>
public decimal? UpgradeAmountThreshold { get; set; }
/// <summary>
/// 升级消费次数门槛。
/// </summary>
public int? UpgradeOrderCountThreshold { get; set; }
/// <summary>
/// 降级观察窗口天数。
/// </summary>
public int DowngradeWindowDays { get; set; } = 90;
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 等级权益JSON
/// </summary>

View File

@@ -0,0 +1,99 @@
using TakeoutSaaS.Domain.Membership.Entities;
namespace TakeoutSaaS.Domain.Membership.Repositories;
/// <summary>
/// 会员聚合仓储契约。
/// </summary>
public interface IMemberRepository
{
/// <summary>
/// 查询租户下会员档案。
/// </summary>
Task<IReadOnlyList<MemberProfile>> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按手机号集合查询会员档案。
/// </summary>
Task<IReadOnlyList<MemberProfile>> GetProfilesByMobilesAsync(
long tenantId,
IReadOnlyCollection<string> mobiles,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询会员档案。
/// </summary>
Task<MemberProfile?> FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增会员档案集合。
/// </summary>
Task AddProfilesAsync(IEnumerable<MemberProfile> profiles, CancellationToken cancellationToken = default);
/// <summary>
/// 更新会员档案。
/// </summary>
Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default);
/// <summary>
/// 查询租户下会员等级。
/// </summary>
Task<IReadOnlyList<MemberTier>> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询会员等级。
/// </summary>
Task<MemberTier?> FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增会员等级。
/// </summary>
Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
/// <summary>
/// 更新会员等级。
/// </summary>
Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
/// <summary>
/// 删除会员等级。
/// </summary>
Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
/// <summary>
/// 查询租户会员日配置。
/// </summary>
Task<MemberDaySetting?> GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增会员日配置。
/// </summary>
Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default);
/// <summary>
/// 更新会员日配置。
/// </summary>
Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default);
/// <summary>
/// 查询会员标签集合。
/// </summary>
Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsAsync(
long tenantId,
long memberProfileId,
CancellationToken cancellationToken = default);
/// <summary>
/// 替换会员标签集合。
/// </summary>
Task ReplaceProfileTagsAsync(
long tenantId,
long memberProfileId,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -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<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IMemberRepository, EfMemberRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -390,6 +390,14 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<MemberTier> MemberTiers => Set<MemberTier>();
/// <summary>
/// 会员标签。
/// </summary>
public DbSet<MemberProfileTag> MemberProfileTags => Set<MemberProfileTag>();
/// <summary>
/// 会员日设置。
/// </summary>
public DbSet<MemberDaySetting> MemberDaySettings => Set<MemberDaySetting>();
/// <summary>
/// 积分流水。
/// </summary>
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
@@ -557,6 +565,8 @@ public sealed class TakeoutAppDbContext(
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
@@ -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<int>();
builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.MemberTierId });
}
private static void ConfigureMemberTier(EntityTypeBuilder<MemberTier> 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<MemberProfileTag> 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<MemberDaySetting> 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<MemberPointLedger> builder)

View File

@@ -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;
/// <summary>
/// 会员聚合 EF Core 仓储实现。
/// </summary>
public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfile>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfile>> GetProfilesByMobilesAsync(
long tenantId,
IReadOnlyCollection<string> mobiles,
CancellationToken cancellationToken = default)
{
if (mobiles.Count == 0)
{
return [];
}
return await context.MemberProfiles
.Where(x => x.TenantId == tenantId && mobiles.Contains(x.Mobile))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberProfile?> FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default)
{
return context.MemberProfiles
.Where(x => x.TenantId == tenantId && x.Id == memberId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddProfilesAsync(IEnumerable<MemberProfile> profiles, CancellationToken cancellationToken = default)
{
var profileList = profiles?.ToList() ?? [];
if (profileList.Count == 0)
{
return;
}
await context.MemberProfiles.AddRangeAsync(profileList, cancellationToken);
}
/// <inheritdoc />
public Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default)
{
context.MemberProfiles.Update(profile);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberTier>> 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);
}
/// <inheritdoc />
public Task<MemberTier?> FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default)
{
return context.MemberTiers
.Where(x => x.TenantId == tenantId && x.Id == tierId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
return context.MemberTiers.AddAsync(tier, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
context.MemberTiers.Update(tier);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
context.MemberTiers.Remove(tier);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<MemberDaySetting?> GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.MemberDaySettings
.Where(x => x.TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
{
return context.MemberDaySettings.AddAsync(setting, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
{
context.MemberDaySettings.Update(setting);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfileTag>> 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);
}
/// <inheritdoc />
public async Task ReplaceProfileTagsAsync(
long tenantId,
long memberProfileId,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken = default)
{
var normalizedTags = (tags ?? Array.Empty<string>())
.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);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,225 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberCenterModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ColorHex",
table: "member_tiers",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "#999999");
migrationBuilder.AddColumn<int>(
name: "DowngradeWindowDays",
table: "member_tiers",
type: "integer",
nullable: false,
defaultValue: 90);
migrationBuilder.AddColumn<string>(
name: "IconKey",
table: "member_tiers",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "user");
migrationBuilder.AddColumn<bool>(
name: "IsDefault",
table: "member_tiers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "UpgradeAmountThreshold",
table: "member_tiers",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "UpgradeOrderCountThreshold",
table: "member_tiers",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "UpgradeRuleType",
table: "member_tiers",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "none");
migrationBuilder.AddColumn<decimal>(
name: "StoredBalance",
table: "member_profiles",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "StoredGiftBalance",
table: "member_profiles",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
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<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用会员日。"),
Weekday = table.Column<int>(type: "integer", nullable: false, comment: "周几1-7。"),
ExtraDiscountRate = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false, comment: "会员日额外折扣。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_day_settings", x => x.Id);
},
comment: "会员日设置。");
migrationBuilder.CreateTable(
name: "member_profile_tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MemberProfileId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
TagName = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "标签名。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_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" });
}
/// <inheritdoc />
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");
}
}
}