Files
tiku-backend.net/Tiku.Infrastructure/Growth/Student/StudentReferralService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

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);
}
}