forked from xiongyuxing/tiku-backend.net
feat: add referral growth endpoints
This commit is contained in:
103
Tiku.Api/Contracts/ReferralDtos.cs
Normal file
103
Tiku.Api/Contracts/ReferralDtos.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Growth;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class ReferralTenantQueryDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReferralInviteDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
[StringLength(2048)]
|
||||
public string? LandingPath { get; set; }
|
||||
|
||||
public ReferralInviteCommand ToCommand()
|
||||
{
|
||||
return new ReferralInviteCommand(Channel, LandingPath);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ResolveReferralDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? Code { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
public ResolveReferralCommand ToCommand()
|
||||
{
|
||||
return new ResolveReferralCommand(Code);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrackReferralEventDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? EventType { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
public Guid? TargetUserId { get; set; }
|
||||
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
public TrackReferralEventCommand ToCommand()
|
||||
{
|
||||
return new TrackReferralEventCommand(RefCode, EventType, Source, TargetUserId, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BindReferralDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public BindReferralCommand ToCommand()
|
||||
{
|
||||
return new BindReferralCommand(RefCode, Source, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReferralQrcodeDto
|
||||
{
|
||||
[StringLength(500)]
|
||||
public string? Page { get; set; }
|
||||
|
||||
[StringLength(128)]
|
||||
public string? Scene { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
[StringLength(2048)]
|
||||
public string? QrcodeUrl { get; set; }
|
||||
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public ReferralQrcodeCommand ToCommand()
|
||||
{
|
||||
return new ReferralQrcodeCommand(Page, Scene, Provider, QrcodeUrl, Metadata);
|
||||
}
|
||||
}
|
||||
127
Tiku.Api/Controllers/ReferralController.cs
Normal file
127
Tiku.Api/Controllers/ReferralController.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Route("api/referral")]
|
||||
public sealed class ReferralController(
|
||||
IReferralService referralService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpPost("invite-code")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[EndpointSummary("生成或查询当前成员邀请码")]
|
||||
[ProducesResponseType<ReferralInviteItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralInviteItem>> InviteCode(
|
||||
ReferralInviteDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await referralService.GetOrCreateInviteCodeAsync(
|
||||
ResolveUserActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("resolve")]
|
||||
[AllowAnonymous]
|
||||
[EndpointSummary("解析推荐邀请码")]
|
||||
[ProducesResponseType<ReferralResolutionItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReferralResolutionItem>> Resolve(
|
||||
ResolveReferralDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await referralService.ResolveAsync(
|
||||
new ReferralActor(await ResolveTenantIdAsync(request.TenantCode, cancellationToken), currentUser.UserId),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("track-event")]
|
||||
[AllowAnonymous]
|
||||
[EndpointSummary("记录推荐行为")]
|
||||
[ProducesResponseType<ReferralTrackResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReferralTrackResult>> TrackEvent(
|
||||
TrackReferralEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await referralService.TrackEventAsync(
|
||||
new ReferralActor(await ResolveTenantIdAsync(request.TenantCode, cancellationToken), currentUser.UserId),
|
||||
request.ToCommand(),
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.FirstOrDefault(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("bind")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[EndpointSummary("绑定当前用户推荐归属")]
|
||||
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralBindResult>> Bind(
|
||||
BindReferralDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await referralService.BindAsync(
|
||||
ResolveUserActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("qrcode")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[EndpointSummary("生成或查询推广二维码")]
|
||||
[ProducesResponseType<ReferralQrcodeItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralQrcodeItem>> Qrcode(
|
||||
ReferralQrcodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await referralService.GetOrCreateQrcodeAsync(
|
||||
ResolveUserActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private ReferralActor ResolveUserActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied");
|
||||
}
|
||||
|
||||
return new ReferralActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue)
|
||||
{
|
||||
return currentTenant.TenantId.Value;
|
||||
}
|
||||
|
||||
var resolvedTenantCode = tenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(resolvedTenantCode))
|
||||
{
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == resolvedTenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Tiku.Application.Assets;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
@@ -194,6 +195,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ReferralException referralException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
referralException.Message,
|
||||
ReferralStatusCode(referralException.Code),
|
||||
referralException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is PaymentProviderException paymentProviderException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -367,4 +378,15 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int ReferralStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"referral_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"referral_code_not_found" => StatusCodes.Status404NotFound,
|
||||
"referral_lead_protected" or "self_referral_not_allowed" => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
106
Tiku.Application/Growth/ReferralModels.cs
Normal file
106
Tiku.Application/Growth/ReferralModels.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tiku.Application.Growth;
|
||||
|
||||
public sealed record ReferralActor(Guid TenantId, Guid? UserId = null);
|
||||
|
||||
public sealed record ReferralInviteCommand(string? Channel = null, string? LandingPath = null);
|
||||
|
||||
public sealed record ResolveReferralCommand(string? Code);
|
||||
|
||||
public sealed record TrackReferralEventCommand(
|
||||
string RefCode,
|
||||
string? EventType,
|
||||
string? Source,
|
||||
Guid? TargetUserId,
|
||||
JsonElement? Metadata);
|
||||
|
||||
public sealed record BindReferralCommand(
|
||||
string RefCode,
|
||||
string? Source,
|
||||
JsonElement? Metadata);
|
||||
|
||||
public sealed record ReferralQrcodeCommand(
|
||||
string? Page,
|
||||
string? Scene,
|
||||
string? Provider,
|
||||
string? QrcodeUrl,
|
||||
JsonElement? Metadata);
|
||||
|
||||
public sealed record ReferralInviteItem(string InviteCode);
|
||||
|
||||
public sealed record ReferralResolutionItem(
|
||||
bool Valid,
|
||||
Guid? InviterId,
|
||||
string? InviteCode,
|
||||
string? Role,
|
||||
string? Name);
|
||||
|
||||
public sealed record ReferralTrackItem(
|
||||
Guid Id,
|
||||
string EventType,
|
||||
string? RefCode,
|
||||
Guid? ReferrerUserId,
|
||||
Guid? TargetUserId,
|
||||
string? Source,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ReferralLeadItem(
|
||||
Guid Id,
|
||||
Guid StudentUserId,
|
||||
Guid? ReferrerUserId,
|
||||
string? RefCode,
|
||||
string Status,
|
||||
DateTimeOffset BoundAt,
|
||||
bool Changed);
|
||||
|
||||
public sealed record ReferralQrcodeItem(
|
||||
Guid Id,
|
||||
string RefCode,
|
||||
string Scene,
|
||||
string Page,
|
||||
string Provider,
|
||||
string? QrcodeUrl,
|
||||
string Status,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record ReferralTrackResult(ReferralTrackItem Item, ReferralLeadItem? Lead, CrmQueuePreviewItem? CrmQueue);
|
||||
|
||||
public sealed record ReferralBindResult(ReferralLeadItem Lead, CrmQueuePreviewItem? CrmQueue);
|
||||
|
||||
public sealed record CrmQueuePreviewItem(Guid Id, string Status, string? Source);
|
||||
|
||||
public interface IReferralService
|
||||
{
|
||||
Task<ReferralInviteItem> GetOrCreateInviteCodeAsync(
|
||||
ReferralActor actor,
|
||||
ReferralInviteCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReferralResolutionItem> ResolveAsync(
|
||||
ReferralActor actor,
|
||||
ResolveReferralCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReferralTrackResult> TrackEventAsync(
|
||||
ReferralActor actor,
|
||||
TrackReferralEventCommand command,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReferralBindResult> BindAsync(
|
||||
ReferralActor actor,
|
||||
BindReferralCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReferralQrcodeItem> GetOrCreateQrcodeAsync(
|
||||
ReferralActor actor,
|
||||
ReferralQrcodeCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class ReferralException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Tiku.Application.Auth;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Profile;
|
||||
@@ -19,6 +20,7 @@ using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Commerce;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Growth;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
@@ -68,6 +70,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
services.AddScoped<IPointService, PointService>();
|
||||
services.AddScoped<IReferralService, ReferralService>();
|
||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||
|
||||
507
Tiku.Infrastructure/Growth/ReferralService.cs
Normal file
507
Tiku.Infrastructure/Growth/ReferralService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
237
Tiku.IntegrationTests/Api/ReferralEndpointTests.cs
Normal file
237
Tiku.IntegrationTests/Api/ReferralEndpointTests.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class ReferralEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Anonymous_invite_code_request_returns_401()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_can_create_invite_code_idempotently()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedReferralAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed.Referrer);
|
||||
|
||||
var first = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto { Channel = "h5" });
|
||||
var second = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto());
|
||||
var firstItem = await first.Content.ReadFromJsonAsync<ReferralInviteItem>();
|
||||
var secondItem = await second.Content.ReadFromJsonAsync<ReferralInviteItem>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
Assert.Equal(firstItem!.InviteCode, secondItem!.InviteCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_can_resolve_and_track_referral_code()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedReferralAsync(factory, includeCode: true);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var resolve = await client.PostAsJsonAsync(
|
||||
"/api/referral/resolve",
|
||||
new ResolveReferralDto { TenantCode = seed.TenantCode, Code = seed.ReferralCode });
|
||||
var track = await client.PostAsJsonAsync(
|
||||
"/api/referral/track-event",
|
||||
new TrackReferralEventDto
|
||||
{
|
||||
TenantCode = seed.TenantCode,
|
||||
RefCode = seed.ReferralCode,
|
||||
EventType = "enter",
|
||||
Source = "qrcode",
|
||||
TargetUserId = seed.Student.UserId,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { page = "landing" })
|
||||
});
|
||||
var resolution = await resolve.Content.ReadFromJsonAsync<ReferralResolutionItem>();
|
||||
var tracked = await track.Content.ReadFromJsonAsync<ReferralTrackResult>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
Assert.True(resolution!.Valid);
|
||||
Assert.Equal(seed.Referrer.UserId, resolution.InviterId);
|
||||
Assert.Equal(HttpStatusCode.OK, track.StatusCode);
|
||||
Assert.Equal(seed.Referrer.UserId, tracked!.Item.ReferrerUserId);
|
||||
Assert.Equal(seed.Student.UserId, tracked.Item.TargetUserId);
|
||||
Assert.NotNull(tracked.Lead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_can_bind_referral_and_create_qrcode()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedReferralAsync(factory, includeCode: true);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed.Student);
|
||||
|
||||
var bind = await client.PostAsJsonAsync(
|
||||
"/api/referral/bind",
|
||||
new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" });
|
||||
var repeatBind = await client.PostAsJsonAsync(
|
||||
"/api/referral/bind",
|
||||
new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" });
|
||||
var qrcode = await client.PostAsJsonAsync(
|
||||
"/api/referral/qrcode",
|
||||
new ReferralQrcodeDto { Page = "pages/home/index", Provider = "wechat-miniapp" });
|
||||
var bindItem = await bind.Content.ReadFromJsonAsync<ReferralBindResult>();
|
||||
var repeatItem = await repeatBind.Content.ReadFromJsonAsync<ReferralBindResult>();
|
||||
var qrcodeItem = await qrcode.Content.ReadFromJsonAsync<ReferralQrcodeItem>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, bind.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, repeatBind.StatusCode);
|
||||
Assert.True(bindItem!.Lead.Changed);
|
||||
Assert.False(repeatItem!.Lead.Changed);
|
||||
Assert.Equal(seed.Referrer.UserId, bindItem.Lead.ReferrerUserId);
|
||||
Assert.Equal(HttpStatusCode.OK, qrcode.StatusCode);
|
||||
Assert.Equal("Ready", qrcodeItem!.Status);
|
||||
Assert.StartsWith("miniapp://", qrcodeItem.QrcodeUrl, StringComparison.Ordinal);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.ReferralLeads, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.StudentUserId == seed.Student.UserId &&
|
||||
item.ReferrerUserId == seed.Referrer.UserId);
|
||||
Assert.Contains(dbContext.ReferralQrcodes, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.UserId == seed.Student.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_referral_without_tenant_returns_404()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/referral/resolve",
|
||||
new ResolveReferralDto { Code = "ABC12345" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<ReferralSeed> SeedReferralAsync(
|
||||
ApiTestFactory factory,
|
||||
bool includeCode = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var tenantCode = tenantId.ToString("N");
|
||||
var referrer = new LoginSeed(tenantId, Guid.NewGuid(), "13800001001");
|
||||
var student = new LoginSeed(tenantId, Guid.NewGuid(), "13800001002");
|
||||
const string referralCode = "ABC12345";
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant { Id = tenantId, Slug = tenantCode, Name = "Referral Tenant" },
|
||||
User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"),
|
||||
User(student.UserId, student.Phone, "Referral Student", "student"),
|
||||
Identity(referrer),
|
||||
Identity(student),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = referrer.UserId,
|
||||
Role = TenantRole.Sales,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = student.UserId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
}
|
||||
};
|
||||
if (includeCode)
|
||||
{
|
||||
entities.Add(new ReferralCode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = referrer.UserId,
|
||||
Code = referralCode,
|
||||
Status = ReferralCodeStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
await factory.SeedAsync(entities.ToArray());
|
||||
return new ReferralSeed(tenantId, tenantCode, referrer, student, referralCode);
|
||||
}
|
||||
|
||||
private static User User(Guid userId, string phone, string name, string role)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = name,
|
||||
PrimaryRole = role
|
||||
};
|
||||
}
|
||||
|
||||
private static UserIdentity Identity(LoginSeed seed)
|
||||
{
|
||||
return new UserIdentity
|
||||
{
|
||||
UserId = seed.UserId,
|
||||
Provider = "password",
|
||||
ProviderSubject = seed.Phone,
|
||||
Phone = seed.Phone,
|
||||
SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!"))
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
private sealed record ReferralSeed(
|
||||
Guid TenantId,
|
||||
string TenantCode,
|
||||
LoginSeed Referrer,
|
||||
LoginSeed Student,
|
||||
string ReferralCode);
|
||||
}
|
||||
Reference in New Issue
Block a user