using System.Globalization; using System.Text; using System.Text.Json; using TakeoutSaaS.Application.App.Members.Dto; using TakeoutSaaS.Domain.Coupons.Entities; using TakeoutSaaS.Domain.Coupons.Enums; using TakeoutSaaS.Domain.Membership.Entities; using TakeoutSaaS.Domain.Membership.Enums; using TakeoutSaaS.Domain.Membership.Repositories; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; using TakeoutSaaS.Domain.Orders.Repositories; using TakeoutSaaS.Domain.Payments.Enums; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Application.App.Members.Handlers; internal static class MemberCenterSupport { private static readonly string[] AvatarColors = [ "#1890ff", "#52c41a", "#fa8c16", "#722ed1", "#eb2f96", "#13c2c2", "#2f54eb", "#f56a00" ]; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; internal static string NormalizePhone(string? value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var chars = value.Where(char.IsDigit).ToArray(); return chars.Length == 0 ? string.Empty : new string(chars); } internal static string MaskPhone(string normalizedPhone) { if (normalizedPhone.Length >= 11) { return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}"; } if (normalizedPhone.Length >= 7) { return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}"; } return normalizedPhone; } internal static string ResolveAvatarColor(string? seed) { var source = string.IsNullOrWhiteSpace(seed) ? "member" : seed; var hash = 0; foreach (var ch in source) { hash = (hash * 31 + ch) & int.MaxValue; } return AvatarColors[hash % AvatarColors.Length]; } internal static string ResolveAvatarText(string? name, string fallbackPhone) { var normalizedName = (name ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(normalizedName)) { return normalizedName[..1]; } return fallbackPhone.Length > 0 ? fallbackPhone[..1] : "会"; } internal static string ResolveDisplayName(MemberProfile profile, MemberOrderMetrics metrics) { if (!string.IsNullOrWhiteSpace(profile.Nickname)) { return profile.Nickname.Trim(); } if (!string.IsNullOrWhiteSpace(metrics.LatestCustomerName)) { return metrics.LatestCustomerName; } return profile.Mobile.Length >= 4 ? $"会员{profile.Mobile[^4..]}" : "会员"; } internal static decimal ResolveDisplayAmount(Order order) { return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount; } internal static string ResolveOrderStatusText(OrderStatus value) { return value switch { OrderStatus.PendingPayment => "待付款", OrderStatus.AwaitingPreparation => "待接单", OrderStatus.InProgress => "制作中", OrderStatus.Ready => "待取餐", OrderStatus.Completed => "已完成", OrderStatus.Cancelled => "已取消", _ => "未知" }; } internal static string NormalizeRuleType(string? value) { return (value ?? string.Empty).Trim().ToLowerInvariant() switch { "amount" => "amount", "count" => "count", "both" => "both", _ => "none" }; } internal static MemberTierBenefitsDto NormalizeBenefits(MemberTierBenefitsDto? source) { var model = source ?? new MemberTierBenefitsDto(); var discountRate = model.Discount.DiscountRate; if (discountRate.HasValue) { discountRate = Math.Clamp(decimal.Round(discountRate.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m); } var multiplier = model.PointMultiplier.Multiplier; if (multiplier.HasValue) { multiplier = Math.Clamp(decimal.Round(multiplier.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m); } var monthlyGrantDay = Math.Clamp(model.MonthlyCoupon.GrantDay <= 0 ? 1 : model.MonthlyCoupon.GrantDay, 1, 28); var monthlyFreeTimes = Math.Max(0, model.FreeDelivery.MonthlyFreeTimes); return new MemberTierBenefitsDto { Discount = new MemberTierDiscountBenefitDto { Enabled = model.Discount.Enabled, DiscountRate = discountRate }, PointMultiplier = new MemberTierPointMultiplierBenefitDto { Enabled = model.PointMultiplier.Enabled, Multiplier = multiplier }, Birthday = new MemberTierBirthdayBenefitDto { Enabled = model.Birthday.Enabled, DoublePointsEnabled = model.Birthday.DoublePointsEnabled, CouponTemplateIds = NormalizeCouponIds(model.Birthday.CouponTemplateIds) }, MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto { Enabled = model.MonthlyCoupon.Enabled, GrantDay = monthlyGrantDay, CouponTemplateIds = NormalizeCouponIds(model.MonthlyCoupon.CouponTemplateIds) }, FreeDelivery = new MemberTierFreeDeliveryBenefitDto { Enabled = model.FreeDelivery.Enabled, MonthlyFreeTimes = monthlyFreeTimes }, PriorityDeliveryEnabled = model.PriorityDeliveryEnabled, ExclusiveServiceEnabled = model.ExclusiveServiceEnabled }; } internal static MemberTierBenefitsDto DeserializeBenefits(string? value) { if (string.IsNullOrWhiteSpace(value)) { return new MemberTierBenefitsDto(); } try { var model = JsonSerializer.Deserialize(value, JsonOptions); return NormalizeBenefits(model); } catch { return new MemberTierBenefitsDto(); } } internal static string SerializeBenefits(MemberTierBenefitsDto benefits) { return JsonSerializer.Serialize(NormalizeBenefits(benefits), JsonOptions); } internal static string BuildConditionText(MemberTier tier) { var ruleType = NormalizeRuleType(tier.UpgradeRuleType); return ruleType switch { "amount" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)}", "count" => $"累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次", "both" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)} 且累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次", _ => "注册即享" }; } internal static IReadOnlyList BuildPerks(MemberTier tier, MemberTierBenefitsDto benefits) { var result = new List(); if (benefits.Discount.Enabled && benefits.Discount.DiscountRate.HasValue) { result.Add($"全场{benefits.Discount.DiscountRate.Value:0.##}折"); } if (benefits.Birthday.Enabled) { if (benefits.Birthday.DoublePointsEnabled && benefits.Birthday.CouponTemplateIds.Count > 0) { result.Add("生日赠券+双倍积分"); } else if (benefits.Birthday.DoublePointsEnabled) { result.Add("生日双倍积分"); } else if (benefits.Birthday.CouponTemplateIds.Count > 0) { result.Add("生日赠券"); } else { result.Add("生日特权"); } } if (benefits.MonthlyCoupon.Enabled) { result.Add("每月赠券"); } if (benefits.PointMultiplier.Enabled && benefits.PointMultiplier.Multiplier.HasValue && benefits.PointMultiplier.Multiplier.Value > 1) { result.Add($"积分{benefits.PointMultiplier.Multiplier.Value:0.##}倍"); } if (benefits.FreeDelivery.Enabled) { result.Add("免配送费"); } if (benefits.PriorityDeliveryEnabled) { result.Add("优先配送"); } if (benefits.ExclusiveServiceEnabled) { result.Add("专属客服"); } if (result.Count == 0 && tier.IsDefault) { return ["积分累计", "会员价商品"]; } return result; } internal static async Task EnsureMemberCenterInitializedAsync( IMemberRepository memberRepository, IOrderRepository orderRepository, ITenantProvider tenantProvider, CancellationToken cancellationToken) { var tenantId = tenantProvider.GetCurrentTenantId(); if (tenantId <= 0) { return; } var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); var changed = false; if (tiers.Count == 0) { foreach (var seed in BuildDefaultTierSeeds()) { await memberRepository.AddTierAsync(seed, cancellationToken); } changed = true; } var memberDaySetting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken); if (memberDaySetting is null) { await memberRepository.AddMemberDaySettingAsync(new MemberDaySetting { IsEnabled = true, Weekday = 2, ExtraDiscountRate = 9m }, cancellationToken); changed = true; } if (changed) { await memberRepository.SaveChangesAsync(cancellationToken); tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); } var allOrders = await orderRepository.SearchAllOrdersAsync( tenantId, null, null, null, null, null, null, null, cancellationToken); var validOrders = allOrders .Where(IsQualifiedOrder) .Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone))) .ToList(); if (validOrders.Count == 0) { return; } var nowUtc = DateTime.UtcNow; var orderMetricsMap = BuildOrderMetrics(validOrders, nowUtc); var mobileKeys = orderMetricsMap.Keys.ToList(); var existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken); var profileLookup = existedProfiles.ToDictionary( item => NormalizePhone(item.Mobile), item => item, StringComparer.Ordinal); var defaultTier = ResolveDefaultTier(tiers); var profilesToAdd = new List(); foreach (var (mobile, metrics) in orderMetricsMap) { if (profileLookup.ContainsKey(mobile)) { continue; } profilesToAdd.Add(new MemberProfile { UserId = 0, Mobile = mobile, Nickname = string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? null : metrics.LatestCustomerName, MemberTierId = defaultTier?.Id, StoredBalance = 0, StoredRechargeBalance = 0, StoredGiftBalance = 0, Status = MemberStatus.Active, PointsBalance = 0, GrowthValue = ToGrowthValue(metrics.TotalAmount), JoinedAt = metrics.FirstOrderAt }); } if (profilesToAdd.Count > 0) { await memberRepository.AddProfilesAsync(profilesToAdd, cancellationToken); await memberRepository.SaveChangesAsync(cancellationToken); existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken); profileLookup = existedProfiles.ToDictionary( item => NormalizePhone(item.Mobile), item => item, StringComparer.Ordinal); } if (profileLookup.Count == 0) { return; } var tierLookup = tiers.ToDictionary(item => item.Id); var orderedTiers = tiers .OrderBy(item => item.SortOrder) .ThenBy(item => item.Id) .ToList(); var hasProfileChanged = false; foreach (var (mobile, profile) in profileLookup) { if (!orderMetricsMap.TryGetValue(mobile, out var metrics)) { continue; } var targetTier = ResolveTargetTier(profile, orderedTiers, tierLookup, metrics, nowUtc); var targetJoinedAt = profile.JoinedAt == default ? metrics.FirstOrderAt : (profile.JoinedAt > metrics.FirstOrderAt ? metrics.FirstOrderAt : profile.JoinedAt); var targetGrowth = ToGrowthValue(metrics.TotalAmount); var targetNickname = string.IsNullOrWhiteSpace(profile.Nickname) ? (string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? profile.Nickname : metrics.LatestCustomerName) : profile.Nickname; if (profile.MemberTierId != targetTier?.Id || profile.GrowthValue != targetGrowth || profile.JoinedAt != targetJoinedAt || profile.Nickname != targetNickname) { profile.MemberTierId = targetTier?.Id; profile.GrowthValue = targetGrowth; profile.JoinedAt = targetJoinedAt; profile.Nickname = targetNickname; await memberRepository.UpdateProfileAsync(profile, cancellationToken); hasProfileChanged = true; } } if (hasProfileChanged) { await memberRepository.SaveChangesAsync(cancellationToken); } } internal static async Task LoadMemberContextAsync( IMemberRepository memberRepository, IOrderRepository orderRepository, ITenantProvider tenantProvider, IReadOnlyCollection visibleStoreIds, CancellationToken cancellationToken) { await EnsureMemberCenterInitializedAsync(memberRepository, orderRepository, tenantProvider, cancellationToken); if (visibleStoreIds.Count == 0) { return new MemberContextSnapshot(); } var tenantId = tenantProvider.GetCurrentTenantId(); var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken); var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken); if (tiers.Count == 0 || profiles.Count == 0) { return new MemberContextSnapshot { TierLookup = tiers.ToDictionary(item => item.Id), Aggregates = [] }; } var orders = await orderRepository.SearchAllOrdersAsync( tenantId, null, null, null, null, null, null, null, cancellationToken); var visibleStoreSet = visibleStoreIds.ToHashSet(); var visibleOrders = orders .Where(IsQualifiedOrder) .Where(order => visibleStoreSet.Contains(order.StoreId)) .Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone))) .ToList(); if (visibleOrders.Count == 0) { return new MemberContextSnapshot { TierLookup = tiers.ToDictionary(item => item.Id), Aggregates = [] }; } var metricsMap = BuildOrderMetrics(visibleOrders, DateTime.UtcNow); var tierLookup = tiers.ToDictionary(item => item.Id); var defaultTier = ResolveDefaultTier(tiers); var aggregates = new List(); foreach (var profile in profiles) { var mobile = NormalizePhone(profile.Mobile); if (string.IsNullOrWhiteSpace(mobile) || !metricsMap.TryGetValue(mobile, out var metrics)) { continue; } var tier = profile.MemberTierId.HasValue && tierLookup.TryGetValue(profile.MemberTierId.Value, out var matchedTier) ? matchedTier : defaultTier; var name = ResolveDisplayName(profile, metrics); var avatarText = ResolveAvatarText(name, mobile); var avatarColor = ResolveAvatarColor(mobile); var joinedAt = profile.JoinedAt == default ? metrics.FirstOrderAt : profile.JoinedAt; aggregates.Add(new MemberAggregate { MemberId = profile.Id, Mobile = mobile, MobileMasked = MaskPhone(mobile), Name = name, AvatarText = avatarText, AvatarColor = avatarColor, JoinedAt = joinedAt, Tier = tier, Profile = profile, Metrics = metrics, IsDormant = metrics.LastOrderAt < DateTime.UtcNow.AddDays(-60) }); } return new MemberContextSnapshot { TierLookup = tierLookup, Aggregates = aggregates .OrderByDescending(item => item.Metrics.LastOrderAt) .ThenByDescending(item => item.MemberId) .ToList() }; } internal static IReadOnlyList ApplyFilters( IReadOnlyList source, string? keyword, long? tierId) { var normalizedKeyword = (keyword ?? string.Empty).Trim(); var keywordDigits = NormalizePhone(normalizedKeyword); return source .Where(item => { if (tierId.HasValue) { if (!item.Tier?.Id.Equals(tierId.Value) ?? true) { return false; } } if (string.IsNullOrWhiteSpace(normalizedKeyword)) { return true; } var matchedByName = item.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase); var matchedByMobile = !string.IsNullOrWhiteSpace(keywordDigits) && item.Mobile.Contains(keywordDigits, StringComparison.Ordinal); return matchedByName || matchedByMobile; }) .ToList(); } internal static string BuildCsv(IReadOnlyList items) { var builder = new StringBuilder(); builder.AppendLine("会员,手机号,等级,累计消费,消费次数,最近消费,储值余额,积分"); foreach (var item in items) { builder.Append(EscapeCsv(item.Name)); builder.Append(','); builder.Append(EscapeCsv(item.MobileMasked)); builder.Append(','); builder.Append(EscapeCsv(item.TierName)); builder.Append(','); builder.Append(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)); builder.Append(','); builder.Append(item.OrderCount.ToString(CultureInfo.InvariantCulture)); builder.Append(','); builder.Append(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); builder.Append(','); builder.Append(item.StoredBalance.ToString("0.00", CultureInfo.InvariantCulture)); builder.Append(','); builder.Append(item.PointsBalance.ToString(CultureInfo.InvariantCulture)); builder.AppendLine(); } return builder.ToString(); } internal static bool IsCouponVisibleToStores(CouponTemplate template, IReadOnlyCollection visibleStoreIds) { if (visibleStoreIds.Count == 0) { return false; } var visibleStoreSet = visibleStoreIds.ToHashSet(); if (string.IsNullOrWhiteSpace(template.StoreScopeJson)) { return true; } try { using var document = JsonDocument.Parse(template.StoreScopeJson); if (!document.RootElement.TryGetProperty("mode", out var modeElement)) { return true; } var mode = modeElement.GetString()?.Trim().ToLowerInvariant() ?? "stores"; if (mode == "all") { return true; } if (!document.RootElement.TryGetProperty("storeIds", out var idsElement) || idsElement.ValueKind != JsonValueKind.Array) { return false; } foreach (var item in idsElement.EnumerateArray()) { if (item.TryGetInt64(out var storeId) && visibleStoreSet.Contains(storeId)) { return true; } } return false; } catch { return false; } } internal static bool IsCouponActive(CouponTemplate template, DateTime nowUtc) { if (template.Status != CouponTemplateStatus.Active) { return false; } if (template.ValidFrom.HasValue && template.ValidFrom.Value > nowUtc) { return false; } if (template.ValidTo.HasValue && template.ValidTo.Value < nowUtc) { return false; } return true; } internal static string ResolveCouponTypeText(CouponType couponType) { return couponType switch { CouponType.AmountOff => "amount_off", CouponType.Percentage => "discount", CouponType.DeliveryFee => "free_delivery", CouponType.Cash => "cash", CouponType.Gift => "gift", _ => "amount_off" }; } internal static string BuildCouponDisplayText(CouponTemplate template) { return template.CouponType switch { CouponType.AmountOff => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0 ? $"满{template.MinimumSpend.Value:0.##}减{template.Value:0.##}" : $"减{template.Value:0.##}", CouponType.Percentage => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0 ? $"满{template.MinimumSpend.Value:0.##}享{template.Value:0.##}折" : $"{template.Value:0.##}折", CouponType.DeliveryFee => "免配送费", CouponType.Cash => $"现金券 {template.Value:0.##}", CouponType.Gift => "礼品券", _ => template.Name }; } internal static MemberTierDetailDto ToTierDetailDto(MemberTier tier, bool canDelete) { var benefits = DeserializeBenefits(tier.BenefitsJson); return new MemberTierDetailDto { TierId = tier.Id, SortOrder = tier.SortOrder, Name = tier.Name, IconKey = tier.IconKey, ColorHex = tier.ColorHex, IsDefault = tier.IsDefault, Rule = new MemberTierRuleDto { UpgradeRuleType = NormalizeRuleType(tier.UpgradeRuleType), UpgradeAmountThreshold = tier.UpgradeAmountThreshold, UpgradeOrderCountThreshold = tier.UpgradeOrderCountThreshold, DowngradeWindowDays = tier.DowngradeWindowDays }, Benefits = benefits, CanDelete = canDelete }; } internal static MemberDaySettingDto ToMemberDaySettingDto(MemberDaySetting setting) { return new MemberDaySettingDto { IsEnabled = setting.IsEnabled, Weekday = setting.Weekday, ExtraDiscountRate = setting.ExtraDiscountRate }; } private static string EscapeCsv(string? value) { var source = value ?? string.Empty; if (!source.Contains(',') && !source.Contains('"') && !source.Contains('\n')) { return source; } return $"\"{source.Replace("\"", "\"\"")}\""; } private static IReadOnlyList NormalizeCouponIds(IReadOnlyList? source) { return (source ?? []) .Where(item => item > 0) .Distinct() .OrderBy(item => item) .ToList(); } private static string FormatAmount(decimal? value) { var amount = Math.Max(0m, value ?? 0m); return $"¥{amount:0.##}"; } private static bool IsQualifiedOrder(Order order) { if (order.Status == OrderStatus.Cancelled) { return false; } if (order.PaymentStatus is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Failed) { return order.PaidAmount > 0; } return true; } private static int ToGrowthValue(decimal totalAmount) { var value = decimal.Round(totalAmount, 0, MidpointRounding.AwayFromZero); if (value > int.MaxValue) { return int.MaxValue; } if (value < 0) { return 0; } return (int)value; } private static MemberTier? ResolveDefaultTier(IReadOnlyList tiers) { return tiers .OrderByDescending(item => item.IsDefault) .ThenBy(item => item.SortOrder) .ThenBy(item => item.Id) .FirstOrDefault(); } private static MemberTier? ResolveTargetTier( MemberProfile profile, IReadOnlyList orderedTiers, IReadOnlyDictionary tierLookup, MemberOrderMetrics metrics, DateTime nowUtc) { if (orderedTiers.Count == 0) { return null; } var defaultTier = ResolveDefaultTier(orderedTiers) ?? orderedTiers[0]; var candidateTier = ResolveTierByMetrics(orderedTiers, metrics.TotalAmount, metrics.TotalOrderCount) ?? defaultTier; if (!profile.MemberTierId.HasValue || !tierLookup.TryGetValue(profile.MemberTierId.Value, out var currentTier)) { return candidateTier; } if (candidateTier.SortOrder > currentTier.SortOrder) { return candidateTier; } var downgradeWindowDays = currentTier.DowngradeWindowDays > 0 ? currentTier.DowngradeWindowDays : 90; var windowStart = nowUtc.AddDays(-downgradeWindowDays); var windowOrders = metrics.Orders .Where(item => item.OrderedAt >= windowStart) .ToList(); var windowAmount = windowOrders.Sum(item => item.Amount); var windowOrderCount = windowOrders.Count; if (MatchTierRule(currentTier, windowAmount, windowOrderCount)) { return currentTier; } var previousTier = orderedTiers .Where(item => item.SortOrder < currentTier.SortOrder) .OrderByDescending(item => item.SortOrder) .ThenByDescending(item => item.Id) .FirstOrDefault(); if (previousTier is null) { return defaultTier; } return previousTier; } private static MemberTier? ResolveTierByMetrics( IReadOnlyList orderedTiers, decimal totalAmount, int totalOrderCount) { MemberTier? matched = null; foreach (var tier in orderedTiers) { if (MatchTierRule(tier, totalAmount, totalOrderCount)) { matched = tier; } } return matched; } private static bool MatchTierRule(MemberTier tier, decimal amount, int orderCount) { var ruleType = NormalizeRuleType(tier.UpgradeRuleType); var thresholdAmount = Math.Max(0m, tier.UpgradeAmountThreshold ?? 0m); var thresholdCount = Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0); return ruleType switch { "amount" => amount >= thresholdAmount, "count" => orderCount >= thresholdCount, "both" => amount >= thresholdAmount && orderCount >= thresholdCount, _ => true }; } private static Dictionary BuildOrderMetrics( IReadOnlyList orders, DateTime nowUtc) { return orders .GroupBy(item => NormalizePhone(item.CustomerPhone), StringComparer.Ordinal) .Where(group => !string.IsNullOrWhiteSpace(group.Key)) .ToDictionary( group => group.Key, group => { var sorted = group .OrderByDescending(item => item.CreatedAt) .ThenByDescending(item => item.Id) .ToList(); var totalAmount = sorted.Sum(ResolveDisplayAmount); var totalOrderCount = sorted.Count; var latestCustomerName = sorted .Select(item => (item.CustomerName ?? string.Empty).Trim()) .FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)) ?? string.Empty; var snapshots = sorted .Select(item => new MemberOrderSnapshot { OrderId = item.Id, OrderNo = item.OrderNo, OrderedAt = item.CreatedAt, Amount = ResolveDisplayAmount(item), Status = item.Status }) .ToList(); return new MemberOrderMetrics { Mobile = group.Key, TotalAmount = totalAmount, TotalOrderCount = totalOrderCount, FirstOrderAt = sorted.Min(item => item.CreatedAt), LastOrderAt = sorted.Max(item => item.CreatedAt), LatestCustomerName = latestCustomerName, Orders = snapshots }; }, StringComparer.Ordinal); } private static IReadOnlyList BuildDefaultTierSeeds() { var defaults = new List { new() { Name = "普通会员", RequiredGrowth = 0, IconKey = "user", ColorHex = "#999999", UpgradeRuleType = "none", UpgradeAmountThreshold = null, UpgradeOrderCountThreshold = null, DowngradeWindowDays = 90, IsDefault = true, SortOrder = 1, BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto()) }, new() { Name = "银卡会员", RequiredGrowth = 500, IconKey = "award", ColorHex = "#1890ff", UpgradeRuleType = "amount", UpgradeAmountThreshold = 500m, UpgradeOrderCountThreshold = null, DowngradeWindowDays = 90, IsDefault = false, SortOrder = 2, BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto { Discount = new MemberTierDiscountBenefitDto { Enabled = true, DiscountRate = 9.8m }, PointMultiplier = new MemberTierPointMultiplierBenefitDto { Enabled = true, Multiplier = 1.2m }, Birthday = new MemberTierBirthdayBenefitDto { Enabled = true, DoublePointsEnabled = false, CouponTemplateIds = [] } }) }, new() { Name = "金卡会员", RequiredGrowth = 2000, IconKey = "trophy", ColorHex = "#fa8c16", UpgradeRuleType = "amount", UpgradeAmountThreshold = 2000m, UpgradeOrderCountThreshold = null, DowngradeWindowDays = 90, IsDefault = false, SortOrder = 3, BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto { Discount = new MemberTierDiscountBenefitDto { Enabled = true, DiscountRate = 9.5m }, PointMultiplier = new MemberTierPointMultiplierBenefitDto { Enabled = true, Multiplier = 1.5m }, Birthday = new MemberTierBirthdayBenefitDto { Enabled = true, DoublePointsEnabled = true, CouponTemplateIds = [] }, MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto { Enabled = true, GrantDay = 1, CouponTemplateIds = [] } }) }, new() { Name = "钻石会员", RequiredGrowth = 5000, IconKey = "gem", ColorHex = "#722ed1", UpgradeRuleType = "amount", UpgradeAmountThreshold = 5000m, UpgradeOrderCountThreshold = null, DowngradeWindowDays = 90, IsDefault = false, SortOrder = 4, BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto { Discount = new MemberTierDiscountBenefitDto { Enabled = true, DiscountRate = 9m }, PointMultiplier = new MemberTierPointMultiplierBenefitDto { Enabled = true, Multiplier = 2m }, Birthday = new MemberTierBirthdayBenefitDto { Enabled = true, DoublePointsEnabled = true, CouponTemplateIds = [] }, MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto { Enabled = true, GrantDay = 1, CouponTemplateIds = [] }, FreeDelivery = new MemberTierFreeDeliveryBenefitDto { Enabled = true, MonthlyFreeTimes = 5 }, PriorityDeliveryEnabled = true, ExclusiveServiceEnabled = true }) } }; return defaults; } } internal sealed class MemberContextSnapshot { internal IReadOnlyList Aggregates { get; init; } = []; internal IReadOnlyDictionary TierLookup { get; init; } = new Dictionary(); } internal sealed class MemberAggregate { internal string AvatarColor { get; init; } = string.Empty; internal string AvatarText { get; init; } = string.Empty; internal long MemberId { get; init; } internal MemberOrderMetrics Metrics { get; init; } = new(); internal string Mobile { get; init; } = string.Empty; internal string MobileMasked { get; init; } = string.Empty; internal string Name { get; init; } = string.Empty; internal MemberProfile Profile { get; init; } = new(); internal MemberTier? Tier { get; init; } internal DateTime JoinedAt { get; init; } internal bool IsDormant { get; init; } } internal sealed class MemberOrderMetrics { internal DateTime FirstOrderAt { get; init; } internal DateTime LastOrderAt { get; init; } internal string LatestCustomerName { get; init; } = string.Empty; internal string Mobile { get; init; } = string.Empty; internal IReadOnlyList Orders { get; init; } = []; internal decimal TotalAmount { get; init; } internal int TotalOrderCount { get; init; } } internal sealed class MemberOrderSnapshot { internal decimal Amount { get; init; } internal long OrderId { get; init; } internal string OrderNo { get; init; } = string.Empty; internal DateTime OrderedAt { get; init; } internal OrderStatus Status { get; init; } }