feat: add referral growth endpoints

This commit is contained in:
xiong
2026-07-26 20:18:09 +08:00
parent 4efccf8355
commit 9fff468bd7
7 changed files with 1105 additions and 0 deletions

View File

@@ -0,0 +1,507 @@
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<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);
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<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 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<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<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 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);
}
}