247 lines
10 KiB
C#
247 lines
10 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Growth;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Growth;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.Growth;
|
|
|
|
internal sealed class StudentReferralService(ReferralServiceDependencies dependencies)
|
|
: ReferralServiceBase(dependencies), IStudentReferralService
|
|
{
|
|
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 growthPersistence.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 growthPersistence.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" })
|
|
};
|
|
growthPersistence.ReferralCodes.Add(referralCode);
|
|
await growthPersistence.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 growthPersistence.ReferralCodes.AsNoTracking()
|
|
join membership in identityPersistence.TenantMemberships.AsNoTracking()
|
|
on new { referralCode.TenantId, referralCode.UserId } equals new
|
|
{ membership.TenantId, membership.UserId }
|
|
join user in identityPersistence.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 growthPersistence.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 growthPersistence.SaveChangesAsync(cancellationToken);
|
|
|
|
growthPersistence.ReferralTracks.Add(track);
|
|
track.LeadId = lead.Id;
|
|
await growthPersistence.SaveChangesAsync(cancellationToken);
|
|
if (setFirstTrack) lead.FirstTrackId = track.Id;
|
|
|
|
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
growthPersistence.ReferralTracks.Add(track);
|
|
}
|
|
|
|
await growthPersistence.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 growthPersistence.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 growthPersistence.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 growthPersistence.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
|
|
};
|
|
growthPersistence.ReferralQrcodes.Add(item);
|
|
}
|
|
|
|
item.UserId = userId;
|
|
item.RefCode = refCode;
|
|
item.QrcodeUrl = generated.QrcodeUrl;
|
|
item.Status = ReferralQrcodeStatus.Ready;
|
|
item.ErrorMessage = null;
|
|
item.Metadata = metadata;
|
|
await growthPersistence.SaveChangesAsync(cancellationToken);
|
|
return ToQrcodeItem(item);
|
|
}
|
|
}
|