Files
TakeoutSaaS.TenantApi/src/Application/TakeoutSaaS.Application/App/Members/Handlers/MemberCenterSupport.cs
MSuMshk d96ca4971a
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
feat(member): implement member center management module
2026-03-03 20:38:31 +08:00

1109 lines
37 KiB
C#

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<MemberTierBenefitsDto>(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<string> BuildPerks(MemberTier tier, MemberTierBenefitsDto benefits)
{
var result = new List<string>();
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<MemberProfile>();
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<MemberContextSnapshot> LoadMemberContextAsync(
IMemberRepository memberRepository,
IOrderRepository orderRepository,
ITenantProvider tenantProvider,
IReadOnlyCollection<long> 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<MemberAggregate>();
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<MemberAggregate> ApplyFilters(
IReadOnlyList<MemberAggregate> 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<MemberListItemDto> 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<long> 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<long> NormalizeCouponIds(IReadOnlyList<long>? 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<MemberTier> tiers)
{
return tiers
.OrderByDescending(item => item.IsDefault)
.ThenBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.FirstOrDefault();
}
private static MemberTier? ResolveTargetTier(
MemberProfile profile,
IReadOnlyList<MemberTier> orderedTiers,
IReadOnlyDictionary<long, MemberTier> 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<MemberTier> 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<string, MemberOrderMetrics> BuildOrderMetrics(
IReadOnlyList<Order> 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<MemberTier> BuildDefaultTierSeeds()
{
var defaults = new List<MemberTier>
{
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<MemberAggregate> Aggregates { get; init; } = [];
internal IReadOnlyDictionary<long, MemberTier> TierLookup { get; init; } = new Dictionary<long, MemberTier>();
}
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<MemberOrderSnapshot> 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; }
}