diff --git a/Tiku.Api/Contracts/ReferralDtos.cs b/Tiku.Api/Contracts/ReferralDtos.cs new file mode 100644 index 0000000..9b8ff25 --- /dev/null +++ b/Tiku.Api/Contracts/ReferralDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs new file mode 100644 index 0000000..1db6353 --- /dev/null +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -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(StatusCodes.Status200OK)] + public async Task> InviteCode( + ReferralInviteDto request, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetOrCreateInviteCodeAsync( + ResolveUserActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("resolve")] + [AllowAnonymous] + [EndpointSummary("解析推荐邀请码")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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(StatusCodes.Status200OK)] + public async Task> Bind( + BindReferralDto request, + CancellationToken cancellationToken) + { + return Ok(await referralService.BindAsync( + ResolveUserActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("qrcode")] + [Authorize(Policy = TikuPolicies.CurrentTenantMember)] + [EndpointSummary("生成或查询推广二维码")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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 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(); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 0c7e6f2..f7197e4 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 + }; + } } diff --git a/Tiku.Application/Growth/ReferralModels.cs b/Tiku.Application/Growth/ReferralModels.cs new file mode 100644 index 0000000..f7d4c51 --- /dev/null +++ b/Tiku.Application/Growth/ReferralModels.cs @@ -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 GetOrCreateInviteCodeAsync( + ReferralActor actor, + ReferralInviteCommand command, + CancellationToken cancellationToken = default); + + Task ResolveAsync( + ReferralActor actor, + ResolveReferralCommand command, + CancellationToken cancellationToken = default); + + Task TrackEventAsync( + ReferralActor actor, + TrackReferralEventCommand command, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default); + + Task BindAsync( + ReferralActor actor, + BindReferralCommand command, + CancellationToken cancellationToken = default); + + Task GetOrCreateQrcodeAsync( + ReferralActor actor, + ReferralQrcodeCommand command, + CancellationToken cancellationToken = default); +} + +public sealed class ReferralException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 539699c..4331681 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs new file mode 100644 index 0000000..346c593 --- /dev/null +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -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 AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) + { + "enter", + "register", + "purchase", + "share", + "scan", + "manual_bind" + }; + + private static readonly HashSet AllowedSources = new(StringComparer.OrdinalIgnoreCase) + { + "share", + "qrcode", + "timeline", + "miniapp", + "h5", + "manual", + "unknown" + }; + + public async Task 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 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 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 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 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 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 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 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 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 bytes = stackalloc byte[8]; + RandomNumberGenerator.Fill(bytes); + Span 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 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); + } +} diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs new file mode 100644 index 0000000..6000a78 --- /dev/null +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -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(); + var secondItem = await second.Content.ReadFromJsonAsync(); + + 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(); + var tracked = await track.Content.ReadFromJsonAsync(); + + 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(); + var repeatItem = await repeatBind.Content.ReadFromJsonAsync(); + var qrcodeItem = await qrcode.Content.ReadFromJsonAsync(); + + 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(); + 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 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 + { + 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); +}