using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; 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) : 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); 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() }; dbContext.ReferralTracks.Add(track); 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); if (lead.FirstTrackId is null) { lead.FirstTrackId = track.Id; } track.LeadId = lead.Id; crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); } await dbContext.SaveChangesAsync(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 qrcodeUrl = NormalizeOptional(command.QrcodeUrl) ?? $"miniapp://{page}?scene={Uri.EscapeDataString(scene)}"; var metadata = command.Metadata ?? JsonSerializer.SerializeToElement(new { generatedBy = "local-placeholder" }); var item = await dbContext.ReferralQrcodes .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Provider == 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 = provider }; dbContext.ReferralQrcodes.Add(item); } item.UserId = userId; item.RefCode = refCode; item.QrcodeUrl ??= qrcodeUrl; item.Status = ReferralQrcodeStatus.Ready; item.ErrorMessage = null; item.Metadata = metadata; await dbContext.SaveChangesAsync(cancellationToken); return ToQrcodeItem(item); } 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 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 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); } }