875 lines
33 KiB
C#
875 lines
33 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Growth;
|
|
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) : IReferralService
|
|
{
|
|
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"enter",
|
|
"register",
|
|
"purchase",
|
|
"share",
|
|
"scan",
|
|
"manual_bind"
|
|
};
|
|
|
|
private static readonly HashSet<string> AllowedSources = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"share",
|
|
"qrcode",
|
|
"timeline",
|
|
"miniapp",
|
|
"h5",
|
|
"manual",
|
|
"unknown"
|
|
};
|
|
|
|
public async Task<ReferralInviteItem> 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<ReferralResolutionItem> 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<ReferralTrackResult> 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<ReferralBindResult> 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<ReferralQrcodeItem> 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<ReferralStatsItem> 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<ReferralList<ReferralStatsItem>> 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<ReferralStatsItem>(referrerIds.Length);
|
|
foreach (var referrerId in referrerIds)
|
|
{
|
|
stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken));
|
|
}
|
|
|
|
return new ReferralList<ReferralStatsItem>(
|
|
stats
|
|
.OrderByDescending(item => item.PaidAmountCents)
|
|
.ThenByDescending(item => item.LeadCount)
|
|
.Take(limit)
|
|
.ToArray());
|
|
}
|
|
|
|
public async Task<ReferralConversionReport> 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<ReferralList<ReferralLeadItem>> 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<ReferralLeadItem>(leads.Select(item => ToLeadItem(item, false)).ToArray());
|
|
}
|
|
|
|
public async Task<ReferralBindResult> 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<ReferralList<ReferralTeamItem>> 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<ReferralTeamItem>(items.Select(ToTeamItem).ToArray());
|
|
}
|
|
|
|
public async Task<ReferralTeamItem> 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<ReferralLead> 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<CrmWebhookQueueItem?> 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<ReferralCode?> 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 isAdmin = await dbContext.TenantMemberships.AnyAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.Status == MembershipStatus.Active &&
|
|
(item.Role == TenantRole.PlatformAdmin ||
|
|
item.Role == TenantRole.TenantOwner ||
|
|
item.Role == TenantRole.TenantAdmin),
|
|
cancellationToken);
|
|
if (!isAdmin)
|
|
{
|
|
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
|
|
}
|
|
}
|
|
|
|
private async Task<ReferralStatsItem> 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<string> 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<byte> bytes = stackalloc byte[8];
|
|
RandomNumberGenerator.Fill(bytes);
|
|
Span<char> 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<string> 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<TEnum>(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<TEnum>())
|
|
{
|
|
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);
|
|
}
|
|
}
|