using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; public sealed class ReferralService( TikuDbContext dbContext, IReferralQrcodeGenerator qrcodeGenerator, ICurrentAccessContext currentAccessContext) : IReferralService { private static readonly HashSet AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) { "enter", "register", "purchase", "share", "scan", "manual_bind" }; private static readonly HashSet AllowedSources = new(StringComparer.OrdinalIgnoreCase) { "share", "qrcode", "timeline", "miniapp", "h5", "manual", "unknown" }; public async Task GetOrCreateInviteCodeAsync( ReferralActor actor, ReferralInviteCommand command, CancellationToken cancellationToken = default) { var userId = RequireUser(actor); await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); var existing = await dbContext.ReferralCodes .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == userId, cancellationToken); if (existing is not null) { if (!string.IsNullOrWhiteSpace(command.Channel)) { existing.Channel = command.Channel.Trim(); } if (!string.IsNullOrWhiteSpace(command.LandingPath)) { existing.LandingPath = command.LandingPath.Trim(); } existing.Status = ReferralCodeStatus.Active; await dbContext.SaveChangesAsync(cancellationToken); return new ReferralInviteItem(existing.Code); } var code = await GenerateUniqueCodeAsync(actor.TenantId, cancellationToken); var referralCode = new ReferralCode { TenantId = actor.TenantId, UserId = userId, Code = code, Channel = NormalizeOptional(command.Channel), LandingPath = NormalizeOptional(command.LandingPath), Metadata = JsonSerializer.SerializeToElement(new { source = "referral_invite_code" }) }; dbContext.ReferralCodes.Add(referralCode); await dbContext.SaveChangesAsync(cancellationToken); return new ReferralInviteItem(code); } public async Task ResolveAsync( ReferralActor actor, ResolveReferralCommand command, CancellationToken cancellationToken = default) { var code = NormalizeCode(command.Code); if (code is null) { return new ReferralResolutionItem(false, null, null, null, null); } var row = await ( from referralCode in dbContext.ReferralCodes.AsNoTracking() join membership in dbContext.TenantMemberships.AsNoTracking() on new { referralCode.TenantId, referralCode.UserId } equals new { membership.TenantId, membership.UserId } join user in dbContext.Users.AsNoTracking() on referralCode.UserId equals user.Id where referralCode.TenantId == actor.TenantId && referralCode.Code == code && referralCode.Status == ReferralCodeStatus.Active && membership.Status == MembershipStatus.Active select new { referralCode.Code, referralCode.UserId, membership.Role, user.Name, user.UserName, user.Phone }) .FirstOrDefaultAsync(cancellationToken); return row is null ? new ReferralResolutionItem(false, null, null, null, null) : new ReferralResolutionItem( true, row.UserId, row.Code, row.Role.ToString(), FirstNonBlank(row.Name, row.UserName, row.Phone)); } public async Task TrackEventAsync( ReferralActor actor, TrackReferralEventCommand command, string? ipAddress, string? userAgent, CancellationToken cancellationToken = default) { var code = NormalizeCode(command.RefCode) ?? throw new ReferralException("Referral code is required.", "referral_code_required"); var eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type"); var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken); await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var track = new ReferralTrack { TenantId = actor.TenantId, ReferrerUserId = resolution?.UserId, TargetUserId = actor.UserId ?? command.TargetUserId, EventType = eventType, RefCode = code, Source = source, IpAddress = NormalizeOptional(ipAddress), UserAgent = Truncate(NormalizeOptional(userAgent), 1024), Metadata = command.Metadata ?? JsonDefaults.Object() }; ReferralLead? lead = null; CrmWebhookQueueItem? crmQueue = null; if (resolution is not null && track.TargetUserId.HasValue && track.TargetUserId.Value != resolution.UserId) { lead = await BindLeadCoreAsync( actor.TenantId, track.TargetUserId.Value, resolution.UserId, code, source, ReferralLeadBindType.FirstTouch, false, command.Metadata, cancellationToken); var setFirstTrack = lead.FirstTrackId is null; if (setFirstTrack) { await dbContext.SaveChangesAsync(cancellationToken); } dbContext.ReferralTracks.Add(track); track.LeadId = lead.Id; await dbContext.SaveChangesAsync(cancellationToken); if (setFirstTrack) { lead.FirstTrackId = track.Id; } crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); } else { dbContext.ReferralTracks.Add(track); } await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue)); } public async Task BindAsync( ReferralActor actor, BindReferralCommand command, CancellationToken cancellationToken = default) { var userId = RequireUser(actor); await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); var code = NormalizeCode(command.RefCode) ?? throw new ReferralException("Referral code is required.", "referral_code_required"); var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken) ?? throw new ReferralException("Referral code was not found.", "referral_code_not_found"); if (resolution.UserId == userId) { throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); } var existing = await dbContext.ReferralLeads .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == userId, cancellationToken); var beforeReferrerId = existing?.ReferrerUserId; var lead = await BindLeadCoreAsync( actor.TenantId, userId, resolution.UserId, code, source, ReferralLeadBindType.FirstTouch, false, command.Metadata, cancellationToken); var crmQueue = beforeReferrerId == lead.ReferrerUserId ? null : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); } public async Task GetOrCreateQrcodeAsync( ReferralActor actor, ReferralQrcodeCommand command, CancellationToken cancellationToken = default) { var userId = RequireUser(actor); await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)).InviteCode; var page = NormalizeOptional(command.Page) ?? "pages/index/index"; var provider = NormalizeOptional(command.Provider) ?? "wechat-miniapp"; var scene = NormalizeOptional(command.Scene) ?? $"ref={refCode}"; var providedQrcodeUrl = NormalizeOptional(command.QrcodeUrl); var generated = providedQrcodeUrl is null ? await qrcodeGenerator.GenerateAsync( new ReferralQrcodeGenerateRequest(actor.TenantId, userId, refCode, provider, page, scene), cancellationToken) : new ReferralQrcodeGenerateResult( providedQrcodeUrl, provider, JsonSerializer.SerializeToElement(new { generatedBy = "external_url" })); var metadata = command.Metadata ?? generated.Metadata; var item = await dbContext.ReferralQrcodes .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Provider == generated.Provider && entry.Scene == scene && entry.Page == page, cancellationToken); if (item is null) { item = new ReferralQrcode { TenantId = actor.TenantId, UserId = userId, RefCode = refCode, Scene = scene, Page = page, Provider = generated.Provider }; dbContext.ReferralQrcodes.Add(item); } item.UserId = userId; item.RefCode = refCode; item.QrcodeUrl = generated.QrcodeUrl; item.Status = ReferralQrcodeStatus.Ready; item.ErrorMessage = null; item.Metadata = metadata; await dbContext.SaveChangesAsync(cancellationToken); return ToQrcodeItem(item); } public async Task GetStatsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var referrerUserId = query.ReferrerUserId ?? actor.UserId; await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken); } public async Task> GetSalesStatsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var limit = Math.Clamp(query.Limit ?? 100, 1, 500); var referrerIdsQuery = dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); if (query.ReferrerUserId.HasValue) { referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); } var referrerIds = await referrerIdsQuery .Select(item => item.ReferrerUserId!.Value) .Distinct() .Take(limit) .ToArrayAsync(cancellationToken); var stats = new List(referrerIds.Length); foreach (var referrerId in referrerIds) { stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); } return new ReferralList( stats .OrderByDescending(item => item.PaidAmountCents) .ThenByDescending(item => item.LeadCount) .Take(limit) .ToArray()); } public async Task GetConversionReportAsync( ReferralAdminActor actor, ReferralConversionQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var today = DateOnly.FromDateTime(DateTime.UtcNow); var endDate = query.EndDate ?? today; var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); if (endDate < startDate) { throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); } var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var leadsQuery = dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.BoundAt >= start && item.BoundAt < endExclusive); if (query.ReferrerUserId.HasValue) { leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); } var leads = await leadsQuery .OrderByDescending(item => item.BoundAt) .ToArrayAsync(cancellationToken); var paidStudentIds = await dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Status == OrderStatus.Paid && item.UserId != null && item.PaidAt != null && item.PaidAt >= start && item.PaidAt < endExclusive) .GroupBy(item => item.UserId!.Value) .Select(group => new { UserId = group.Key, AmountCents = group.Sum(item => item.AmountCents) }) .ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken); var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId)); var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId)); var top = await GetSalesStatsAsync( actor, new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20), cancellationToken); return new ReferralConversionReport( startDate, endDate, leads.Length, paidLeadCount, paidAmountCents, leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2), top.Items, leads .Where(item => !paidStudentIds.ContainsKey(item.StudentUserId)) .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) .Select(item => ToLeadItem(item, false)) .ToArray()); } public async Task> GetClientsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var referrerUserId = query.ReferrerUserId ?? actor.UserId; await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); var leads = await dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId == referrerUserId) .OrderByDescending(item => item.BoundAt) .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) .ToArrayAsync(cancellationToken); return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); } public async Task ManualBindAsync( ReferralAdminActor actor, ManualBindReferralCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); if (command.StudentUserId == command.ReferrerUserId) { throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); } var refCode = await dbContext.ReferralCodes .Where(item => item.TenantId == actor.TenantId && item.UserId == command.ReferrerUserId && item.Status == ReferralCodeStatus.Active) .Select(item => item.Code) .FirstOrDefaultAsync(cancellationToken); if (refCode is null) { refCode = (await GetOrCreateInviteCodeAsync( new ReferralActor(actor.TenantId, command.ReferrerUserId), new ReferralInviteCommand("manual"), cancellationToken)).InviteCode; } var before = await dbContext.ReferralLeads .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == command.StudentUserId, cancellationToken); var lead = await BindLeadCoreAsync( actor.TenantId, command.StudentUserId, command.ReferrerUserId, refCode, NormalizeChoice(command.Source, AllowedSources, "manual", "invalid_referral_source"), ReferralLeadBindType.Manual, command.Force, command.Metadata, cancellationToken); lead.AssignedBy = actor.UserId; lead.AssignedAt = DateTimeOffset.UtcNow; var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId ? null : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); } public async Task> GetTeamAsync( ReferralAdminActor actor, ReferralTeamQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var edges = dbContext.ReferralTeamEdges .AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.LeaderUserId.HasValue) { edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); } var items = await edges .OrderBy(item => item.RelationType) .ThenBy(item => item.MemberUserId) .ToArrayAsync(cancellationToken); return new ReferralList(items.Select(ToTeamItem).ToArray()); } public async Task UpsertTeamAsync( ReferralAdminActor actor, UpsertReferralTeamCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken); if (command.LeaderUserId.HasValue) { await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken); } var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation"); var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status"); var edge = await dbContext.ReferralTeamEdges .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.MemberUserId == command.MemberUserId && item.RelationType == relationType, cancellationToken); if (edge is null) { edge = new ReferralTeamEdge { TenantId = actor.TenantId, MemberUserId = command.MemberUserId, RelationType = relationType }; dbContext.ReferralTeamEdges.Add(edge); } edge.LeaderUserId = command.LeaderUserId; edge.Status = status; edge.Metadata = command.Metadata ?? JsonDefaults.Object(); await dbContext.SaveChangesAsync(cancellationToken); return ToTeamItem(edge); } private async Task BindLeadCoreAsync( Guid tenantId, Guid studentUserId, Guid referrerUserId, string refCode, string source, ReferralLeadBindType bindType, bool force, JsonElement? metadata, CancellationToken cancellationToken) { await AssertActiveMemberAsync(tenantId, studentUserId, cancellationToken); await AssertActiveMemberAsync(tenantId, referrerUserId, cancellationToken); var now = DateTimeOffset.UtcNow; var lead = await dbContext.ReferralLeads .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StudentUserId == studentUserId, cancellationToken); if (lead is not null) { if (lead.ReferrerUserId == referrerUserId) { return lead; } if (!force && lead.Status == ReferralLeadStatus.Protected && (lead.ProtectedUntil is null || lead.ProtectedUntil > now)) { throw new ReferralException("Referral lead is protected and cannot be rebound.", "referral_lead_protected"); } } else { lead = new ReferralLead { TenantId = tenantId, StudentUserId = studentUserId }; dbContext.ReferralLeads.Add(lead); } lead.ReferrerUserId = referrerUserId; lead.RefCode = refCode; lead.Source = source; lead.BindType = bindType; lead.Status = ReferralLeadStatus.Protected; lead.ProtectedUntil = now.AddDays(30); lead.BoundAt = now; lead.Metadata = metadata ?? JsonDefaults.Object(); return lead; } private async Task EnqueueCrmIfEnabledAsync( Guid tenantId, ReferralLead lead, string source, CancellationToken cancellationToken) { var config = await dbContext.CrmConfigs .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken); if (config is null || string.IsNullOrWhiteSpace(config.Url)) { return null; } var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture); var idempotencyKey = $"{source}:{recordId}"; var existing = await dbContext.CrmWebhookQueue .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken); if (existing is not null) { return existing; } var queue = new CrmWebhookQueueItem { TenantId = tenantId, RecordId = recordId, LeadId = recordId, Source = source, Provider = "webhook", Status = CrmWebhookQueueStatus.Pending, ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(config.DelaySeconds ?? 0, 0)), IdempotencyKey = idempotencyKey, TargetUrl = config.Url, Payload = JsonSerializer.SerializeToElement(new { tenantId, leadId = lead.Id, lead.StudentUserId, lead.ReferrerUserId, lead.RefCode, lead.Source, lead.BoundAt, config.FormName, config.ExamType }) }; dbContext.CrmWebhookQueue.Add(queue); return queue; } private async Task ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken) { return await dbContext.ReferralCodes .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code && item.Status == ReferralCodeStatus.Active, cancellationToken); } private async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken); if (!exists) { throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); } } private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.TenantId != actor.TenantId || access.UserId != actor.UserId || !access.HasTenantPermission(BackendPermissions.TenantCrmManage)) { throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); } } private async Task BuildStatsAsync( Guid tenantId, Guid referrerUserId, CancellationToken cancellationToken) { var user = await dbContext.Users.AsNoTracking() .Where(item => item.Id == referrerUserId) .Select(item => new { item.Name, item.UserName, item.Phone }) .FirstOrDefaultAsync(cancellationToken); var membership = await dbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) .Select(item => item.Role) .FirstOrDefaultAsync(cancellationToken); var inviteCode = await dbContext.ReferralCodes.AsNoTracking() .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId && item.Status == ReferralCodeStatus.Active) .Select(item => item.Code) .FirstOrDefaultAsync(cancellationToken); var leads = await dbContext.ReferralLeads.AsNoTracking() .Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId) .Select(item => item.StudentUserId) .ToArrayAsync(cancellationToken); var paid = leads.Length == 0 ? [] : await dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == OrderStatus.Paid && item.UserId != null && leads.Contains(item.UserId.Value)) .GroupBy(item => item.UserId!.Value) .Select(group => new { UserId = group.Key, AmountCents = group.Sum(item => item.AmountCents) }) .ToArrayAsync(cancellationToken); var trackCount = await dbContext.ReferralTracks.AsNoTracking() .CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken); return new ReferralStatsItem( referrerUserId, FirstNonBlank(user?.Name, user?.UserName, user?.Phone), membership.ToString(), inviteCode, leads.Length, paid.Length, paid.Sum(item => item.AmountCents), trackCount, leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2)); } private async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) { for (var attempt = 0; attempt < 20; attempt++) { var code = GenerateCode(); var exists = await dbContext.ReferralCodes.AnyAsync( item => item.TenantId == tenantId && item.Code == code, cancellationToken); if (!exists) { return code; } } throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed"); } private static string GenerateCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; Span bytes = stackalloc byte[8]; RandomNumberGenerator.Fill(bytes); Span chars = stackalloc char[8]; for (var index = 0; index < chars.Length; index++) { chars[index] = alphabet[bytes[index] % alphabet.Length]; } return new string(chars); } private static Guid RequireUser(ReferralActor actor) { return actor.UserId ?? throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); } private static string? NormalizeCode(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToUpperInvariant(); } private static string NormalizeChoice( string? value, HashSet allowed, string defaultValue, string errorCode) { if (string.IsNullOrWhiteSpace(value)) { return defaultValue; } var normalized = value.Trim().ToLowerInvariant(); return allowed.Contains(normalized) ? normalized : throw new ReferralException("Referral value was invalid.", errorCode); } private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum { if (string.IsNullOrWhiteSpace(value)) { return defaultValue; } var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); foreach (var enumValue in Enum.GetValues()) { if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) { return enumValue; } } throw new ReferralException("Referral enum value was invalid.", errorCode); } private static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static string? Truncate(string? value, int maxLength) { return value is null || value.Length <= maxLength ? value : value[..maxLength]; } private static string? FirstNonBlank(params string?[] values) { return values.Select(NormalizeOptional).FirstOrDefault(value => value is not null); } private static ReferralTrackItem ToTrackItem(ReferralTrack item) { return new ReferralTrackItem( item.Id, item.EventType, item.RefCode, item.ReferrerUserId, item.TargetUserId, item.Source, item.CreatedAt); } private static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed) { return new ReferralLeadItem( item.Id, item.StudentUserId, item.ReferrerUserId, item.RefCode, item.Status.ToString(), item.BoundAt, changed); } private static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item) { return new ReferralQrcodeItem( item.Id, item.RefCode, item.Scene, item.Page, item.Provider, item.QrcodeUrl, item.Status.ToString(), item.Metadata); } private static CrmQueuePreviewItem? ToQueuePreview(CrmWebhookQueueItem? item) { return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source); } private static ReferralTeamItem ToTeamItem(ReferralTeamEdge item) { return new ReferralTeamItem( item.Id, item.MemberUserId, item.LeaderUserId, item.RelationType.ToString(), item.Status.ToString(), item.Metadata); } }