diff --git a/Tiku.Api/Contracts/ReferralDtos.cs b/Tiku.Api/Contracts/ReferralDtos.cs index 9b8ff25..898c474 100644 --- a/Tiku.Api/Contracts/ReferralDtos.cs +++ b/Tiku.Api/Contracts/ReferralDtos.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Text.Json; using Tiku.Application.Growth; @@ -101,3 +102,102 @@ public sealed class ReferralQrcodeDto return new ReferralQrcodeCommand(Page, Scene, Provider, QrcodeUrl, Metadata); } } + +public sealed class ReferralStatsQueryDto +{ + public Guid? ReferrerUserId { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public ReferralStatsQuery ToQuery() + { + return new ReferralStatsQuery(ReferrerUserId, Limit); + } +} + +public sealed class ReferralConversionQueryDto +{ + public Guid? ReferrerUserId { get; set; } + + [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] + public string? StartDate { get; set; } + + [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] + public string? EndDate { get; set; } + + [Range(1, 365)] + public int? Days { get; set; } + + [Range(1, 100)] + public int? Limit { get; set; } + + public ReferralConversionQuery ToQuery() + { + return new ReferralConversionQuery( + ReferrerUserId, + ParseDate(StartDate), + ParseDate(EndDate), + Days, + Limit); + } + + private static DateOnly? ParseDate(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + } +} + +public sealed class ManualBindReferralDto +{ + [Required] + public Guid StudentUserId { get; set; } + + [Required] + public Guid ReferrerUserId { get; set; } + + [StringLength(100)] + public string? Source { get; set; } + + public bool Force { get; set; } + + public JsonElement? Metadata { get; set; } + + public ManualBindReferralCommand ToCommand() + { + return new ManualBindReferralCommand(StudentUserId, ReferrerUserId, Source, Force, Metadata); + } +} + +public sealed class ReferralTeamQueryDto +{ + public Guid? LeaderUserId { get; set; } + + public ReferralTeamQuery ToQuery() + { + return new ReferralTeamQuery(LeaderUserId); + } +} + +public sealed class UpsertReferralTeamDto +{ + [Required] + public Guid MemberUserId { get; set; } + + public Guid? LeaderUserId { get; set; } + + [StringLength(50)] + public string? RelationType { get; set; } + + [StringLength(50)] + public string? Status { get; set; } + + public JsonElement? Metadata { get; set; } + + public UpsertReferralTeamCommand ToCommand() + { + return new UpsertReferralTeamCommand(MemberUserId, LeaderUserId, RelationType, Status, Metadata); + } +} diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs index 1db6353..30db0fb 100644 --- a/Tiku.Api/Controllers/ReferralController.cs +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -92,6 +92,104 @@ public sealed class ReferralController( cancellationToken)); } + [HttpGet("stats")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("查询推荐人个人统计")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Stats( + [FromQuery] ReferralStatsQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetStatsAsync( + ResolveAdminActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpGet("sales-stats")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("查询销售推荐统计排行")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> SalesStats( + [FromQuery] ReferralStatsQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetSalesStatsAsync( + ResolveAdminActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpGet("conversion-report")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("查询推荐转化报告")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ConversionReport( + [FromQuery] ReferralConversionQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetConversionReportAsync( + ResolveAdminActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpGet("sales-clients")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("查询推荐人名下客户")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> SalesClients( + [FromQuery] ReferralStatsQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetClientsAsync( + ResolveAdminActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpPost("manual-bind")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("人工调整学生推荐归属")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ManualBind( + ManualBindReferralDto request, + CancellationToken cancellationToken) + { + return Ok(await referralService.ManualBindAsync( + ResolveAdminActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("team")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("查询推荐团队成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> Team( + [FromQuery] ReferralTeamQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await referralService.GetTeamAsync( + ResolveAdminActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpPut("team")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [EndpointSummary("新增或更新推荐团队关系")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertTeam( + UpsertReferralTeamDto request, + CancellationToken cancellationToken) + { + return Ok(await referralService.UpsertTeamAsync( + ResolveAdminActor(), + request.ToCommand(), + cancellationToken)); + } + private ReferralActor ResolveUserActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) @@ -102,6 +200,16 @@ public sealed class ReferralController( return new ReferralActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } + private ReferralAdminActor ResolveAdminActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new ReferralException("Referral admin actor was not resolved.", "referral_access_denied"); + } + + return new ReferralAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } + private async Task ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken) { if (currentTenant.TenantId.HasValue) diff --git a/Tiku.Application/Growth/ReferralModels.cs b/Tiku.Application/Growth/ReferralModels.cs index f7d4c51..a271699 100644 --- a/Tiku.Application/Growth/ReferralModels.cs +++ b/Tiku.Application/Growth/ReferralModels.cs @@ -4,6 +4,8 @@ namespace Tiku.Application.Growth; public sealed record ReferralActor(Guid TenantId, Guid? UserId = null); +public sealed record ReferralAdminActor(Guid TenantId, Guid UserId); + public sealed record ReferralInviteCommand(string? Channel = null, string? LandingPath = null); public sealed record ResolveReferralCommand(string? Code); @@ -27,6 +29,26 @@ public sealed record ReferralQrcodeCommand( string? QrcodeUrl, JsonElement? Metadata); +public sealed record ReferralStatsQuery(Guid? ReferrerUserId = null, int? Limit = null); + +public sealed record ReferralConversionQuery(Guid? ReferrerUserId = null, DateOnly? StartDate = null, DateOnly? EndDate = null, int? Days = null, int? Limit = null); + +public sealed record ManualBindReferralCommand( + Guid StudentUserId, + Guid ReferrerUserId, + string? Source, + bool Force, + JsonElement? Metadata); + +public sealed record ReferralTeamQuery(Guid? LeaderUserId = null); + +public sealed record UpsertReferralTeamCommand( + Guid MemberUserId, + Guid? LeaderUserId, + string? RelationType, + string? Status, + JsonElement? Metadata); + public sealed record ReferralInviteItem(string InviteCode); public sealed record ReferralResolutionItem( @@ -70,6 +92,37 @@ public sealed record ReferralBindResult(ReferralLeadItem Lead, CrmQueuePreviewIt public sealed record CrmQueuePreviewItem(Guid Id, string Status, string? Source); +public sealed record ReferralStatsItem( + Guid ReferrerUserId, + string? Name, + string Role, + string? InviteCode, + int LeadCount, + int PaidLeadCount, + int PaidAmountCents, + int TrackCount, + decimal ConversionRate); + +public sealed record ReferralList(IReadOnlyCollection Items); + +public sealed record ReferralConversionReport( + DateOnly StartDate, + DateOnly EndDate, + int LeadCount, + int PaidLeadCount, + int PaidAmountCents, + decimal ConversionRate, + IReadOnlyCollection TopReferrers, + IReadOnlyCollection RecentUnconvertedLeads); + +public sealed record ReferralTeamItem( + Guid Id, + Guid MemberUserId, + Guid? LeaderUserId, + string RelationType, + string Status, + JsonElement Metadata); + public interface IReferralService { Task GetOrCreateInviteCodeAsync( @@ -98,6 +151,41 @@ public interface IReferralService ReferralActor actor, ReferralQrcodeCommand command, CancellationToken cancellationToken = default); + + Task GetStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default); + + Task> GetSalesStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default); + + Task GetConversionReportAsync( + ReferralAdminActor actor, + ReferralConversionQuery query, + CancellationToken cancellationToken = default); + + Task> GetClientsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default); + + Task ManualBindAsync( + ReferralAdminActor actor, + ManualBindReferralCommand command, + CancellationToken cancellationToken = default); + + Task> GetTeamAsync( + ReferralAdminActor actor, + ReferralTeamQuery query, + CancellationToken cancellationToken = default); + + Task UpsertTeamAsync( + ReferralAdminActor actor, + UpsertReferralTeamCommand command, + CancellationToken cancellationToken = default); } public sealed class ReferralException(string message, string code) : Exception(message) diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs index 346c593..98dc7fb 100644 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; +using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Identity; @@ -260,6 +261,247 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService return ToQrcodeItem(item); } + public async Task GetStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var referrerUserId = query.ReferrerUserId ?? actor.UserId; + await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); + return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken); + } + + public async Task> GetSalesStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var limit = Math.Clamp(query.Limit ?? 100, 1, 500); + var referrerIdsQuery = dbContext.ReferralLeads + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); + if (query.ReferrerUserId.HasValue) + { + referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); + } + + var referrerIds = await referrerIdsQuery + .Select(item => item.ReferrerUserId!.Value) + .Distinct() + .Take(limit) + .ToArrayAsync(cancellationToken); + var stats = new List(referrerIds.Length); + foreach (var referrerId in referrerIds) + { + stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); + } + + return new ReferralList( + stats + .OrderByDescending(item => item.PaidAmountCents) + .ThenByDescending(item => item.LeadCount) + .Take(limit) + .ToArray()); + } + + public async Task GetConversionReportAsync( + ReferralAdminActor actor, + ReferralConversionQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var endDate = query.EndDate ?? today; + var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); + if (endDate < startDate) + { + throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); + } + + var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var leadsQuery = dbContext.ReferralLeads + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.BoundAt >= start && + item.BoundAt < endExclusive); + if (query.ReferrerUserId.HasValue) + { + leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); + } + + var leads = await leadsQuery + .OrderByDescending(item => item.BoundAt) + .ToArrayAsync(cancellationToken); + var paidStudentIds = await dbContext.Orders.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.Status == OrderStatus.Paid && + item.UserId != null && + item.PaidAt != null && + item.PaidAt >= start && + item.PaidAt < endExclusive) + .GroupBy(item => item.UserId!.Value) + .Select(group => new + { + UserId = group.Key, + AmountCents = group.Sum(item => item.AmountCents) + }) + .ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken); + var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId)); + var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId)); + var top = await GetSalesStatsAsync( + actor, + new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20), + cancellationToken); + + return new ReferralConversionReport( + startDate, + endDate, + leads.Length, + paidLeadCount, + paidAmountCents, + leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2), + top.Items, + leads + .Where(item => !paidStudentIds.ContainsKey(item.StudentUserId)) + .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) + .Select(item => ToLeadItem(item, false)) + .ToArray()); + } + + public async Task> GetClientsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var referrerUserId = query.ReferrerUserId ?? actor.UserId; + await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); + var leads = await dbContext.ReferralLeads + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.ReferrerUserId == referrerUserId) + .OrderByDescending(item => item.BoundAt) + .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) + .ToArrayAsync(cancellationToken); + return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); + } + + public async Task ManualBindAsync( + ReferralAdminActor actor, + ManualBindReferralCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.StudentUserId == command.ReferrerUserId) + { + throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); + } + + var refCode = await dbContext.ReferralCodes + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == command.ReferrerUserId && + item.Status == ReferralCodeStatus.Active) + .Select(item => item.Code) + .FirstOrDefaultAsync(cancellationToken); + if (refCode is null) + { + refCode = (await GetOrCreateInviteCodeAsync( + new ReferralActor(actor.TenantId, command.ReferrerUserId), + new ReferralInviteCommand("manual"), + cancellationToken)).InviteCode; + } + + var before = await dbContext.ReferralLeads + .AsNoTracking() + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.StudentUserId == command.StudentUserId, + cancellationToken); + var lead = await BindLeadCoreAsync( + actor.TenantId, + command.StudentUserId, + command.ReferrerUserId, + refCode, + NormalizeChoice(command.Source, AllowedSources, "manual", "invalid_referral_source"), + ReferralLeadBindType.Manual, + command.Force, + command.Metadata, + cancellationToken); + lead.AssignedBy = actor.UserId; + lead.AssignedAt = DateTimeOffset.UtcNow; + var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId + ? null + : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); + } + + public async Task> GetTeamAsync( + ReferralAdminActor actor, + ReferralTeamQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var edges = dbContext.ReferralTeamEdges + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (query.LeaderUserId.HasValue) + { + edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); + } + + var items = await edges + .OrderBy(item => item.RelationType) + .ThenBy(item => item.MemberUserId) + .ToArrayAsync(cancellationToken); + return new ReferralList(items.Select(ToTeamItem).ToArray()); + } + + public async Task UpsertTeamAsync( + ReferralAdminActor actor, + UpsertReferralTeamCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken); + if (command.LeaderUserId.HasValue) + { + await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken); + } + + var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation"); + var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status"); + var edge = await dbContext.ReferralTeamEdges + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.MemberUserId == command.MemberUserId && + item.RelationType == relationType, + cancellationToken); + if (edge is null) + { + edge = new ReferralTeamEdge + { + TenantId = actor.TenantId, + MemberUserId = command.MemberUserId, + RelationType = relationType + }; + dbContext.ReferralTeamEdges.Add(edge); + } + + edge.LeaderUserId = command.LeaderUserId; + edge.Status = status; + edge.Metadata = command.Metadata ?? JsonDefaults.Object(); + await dbContext.SaveChangesAsync(cancellationToken); + return ToTeamItem(edge); + } + private async Task BindLeadCoreAsync( Guid tenantId, Guid studentUserId, @@ -388,6 +630,77 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService } } + private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) + { + var isAdmin = await dbContext.TenantMemberships.AnyAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active && + (item.Role == TenantRole.PlatformAdmin || + item.Role == TenantRole.TenantOwner || + item.Role == TenantRole.TenantAdmin), + cancellationToken); + if (!isAdmin) + { + throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); + } + } + + private async Task BuildStatsAsync( + Guid tenantId, + Guid referrerUserId, + CancellationToken cancellationToken) + { + var user = await dbContext.Users.AsNoTracking() + .Where(item => item.Id == referrerUserId) + .Select(item => new { item.Name, item.Username, item.Phone }) + .FirstOrDefaultAsync(cancellationToken); + var membership = await dbContext.TenantMemberships.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) + .Select(item => item.Role) + .FirstOrDefaultAsync(cancellationToken); + var inviteCode = await dbContext.ReferralCodes.AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.UserId == referrerUserId && + item.Status == ReferralCodeStatus.Active) + .Select(item => item.Code) + .FirstOrDefaultAsync(cancellationToken); + var leads = await dbContext.ReferralLeads.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId) + .Select(item => item.StudentUserId) + .ToArrayAsync(cancellationToken); + var paid = leads.Length == 0 + ? [] + : await dbContext.Orders.AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.Status == OrderStatus.Paid && + item.UserId != null && + leads.Contains(item.UserId.Value)) + .GroupBy(item => item.UserId!.Value) + .Select(group => new + { + UserId = group.Key, + AmountCents = group.Sum(item => item.AmountCents) + }) + .ToArrayAsync(cancellationToken); + var trackCount = await dbContext.ReferralTracks.AsNoTracking() + .CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken); + + return new ReferralStatsItem( + referrerUserId, + FirstNonBlank(user?.Name, user?.Username, user?.Phone), + membership.ToString(), + inviteCode, + leads.Length, + paid.Length, + paid.Sum(item => item.AmountCents), + trackCount, + leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2)); + } + private async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) { for (var attempt = 0; attempt < 20; attempt++) @@ -448,6 +761,26 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService : throw new ReferralException("Referral value was invalid.", errorCode); } + private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) + where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); + foreach (var enumValue in Enum.GetValues()) + { + if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + { + return enumValue; + } + } + + throw new ReferralException("Referral enum value was invalid.", errorCode); + } + private static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -504,4 +837,15 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService { return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source); } + + private static ReferralTeamItem ToTeamItem(ReferralTeamEdge item) + { + return new ReferralTeamItem( + item.Id, + item.MemberUserId, + item.LeaderUserId, + item.RelationType.ToString(), + item.Status.ToString(), + item.Metadata); + } } diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs index 6000a78..b18e807 100644 --- a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Growth; +using Tiku.Domain.Commerce; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; @@ -131,6 +132,77 @@ public sealed class ReferralEndpointTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task Non_admin_cannot_access_referral_management() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedReferralAsync(factory, includeCode: true); + using var client = factory.CreateClient(); + await LoginAsync(client, seed.Student); + + var response = await client.GetAsync("/api/referral/stats"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Admin_can_manage_referral_team_and_query_stats() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedReferralAsync(factory, includeCode: true); + using var client = factory.CreateClient(); + await LoginAsync(client, seed.Admin); + + var manualBind = await client.PostAsJsonAsync( + "/api/referral/manual-bind", + new ManualBindReferralDto + { + StudentUserId = seed.Student.UserId, + ReferrerUserId = seed.Referrer.UserId, + Source = "manual", + Force = true + }); + var team = await client.PutAsJsonAsync( + "/api/referral/team", + new UpsertReferralTeamDto + { + MemberUserId = seed.Referrer.UserId, + LeaderUserId = seed.Admin.UserId, + RelationType = "sales_team", + Status = "active" + }); + await factory.SeedAsync(new Order + { + TenantId = seed.TenantId, + UserId = seed.Student.UserId, + OrderNo = "R202607260001", + Status = OrderStatus.Paid, + ProductType = "svip", + ProductName = "SVIP", + AmountCents = 9900, + PaidAt = DateTimeOffset.UtcNow + }); + + var stats = await client.GetAsync($"/api/referral/stats?referrerUserId={seed.Referrer.UserId}"); + var salesStats = await client.GetAsync("/api/referral/sales-stats"); + var conversion = await client.GetAsync("/api/referral/conversion-report?days=30"); + var clients = await client.GetAsync($"/api/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); + var teamList = await client.GetAsync($"/api/referral/team?leaderUserId={seed.Admin.UserId}"); + var statsItem = await stats.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, manualBind.StatusCode); + Assert.Equal(HttpStatusCode.OK, team.StatusCode); + Assert.Equal(HttpStatusCode.OK, stats.StatusCode); + Assert.Equal(1, statsItem!.LeadCount); + Assert.Equal(1, statsItem.PaidLeadCount); + Assert.Equal(9900, statsItem.PaidAmountCents); + Assert.Equal(HttpStatusCode.OK, salesStats.StatusCode); + Assert.Equal(HttpStatusCode.OK, conversion.StatusCode); + Assert.Equal(HttpStatusCode.OK, clients.StatusCode); + Assert.Equal(HttpStatusCode.OK, teamList.StatusCode); + Assert.Contains(seed.Referrer.UserId.ToString(), await teamList.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + private static async Task SeedReferralAsync( ApiTestFactory factory, bool includeCode = false) @@ -139,14 +211,17 @@ public sealed class ReferralEndpointTests var tenantCode = tenantId.ToString("N"); var referrer = new LoginSeed(tenantId, Guid.NewGuid(), "13800001001"); var student = new LoginSeed(tenantId, Guid.NewGuid(), "13800001002"); + var admin = new LoginSeed(tenantId, Guid.NewGuid(), "13800001003"); 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"), + User(admin.UserId, admin.Phone, "Referral Admin", "tenant_admin"), Identity(referrer), Identity(student), + Identity(admin), new TenantMembership { TenantId = tenantId, @@ -160,6 +235,13 @@ public sealed class ReferralEndpointTests UserId = student.UserId, Role = TenantRole.Student, Status = MembershipStatus.Active + }, + new TenantMembership + { + TenantId = tenantId, + UserId = admin.UserId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active } }; if (includeCode) @@ -174,7 +256,7 @@ public sealed class ReferralEndpointTests } await factory.SeedAsync(entities.ToArray()); - return new ReferralSeed(tenantId, tenantCode, referrer, student, referralCode); + return new ReferralSeed(tenantId, tenantCode, referrer, student, admin, referralCode); } private static User User(Guid userId, string phone, string name, string role) @@ -233,5 +315,6 @@ public sealed class ReferralEndpointTests string TenantCode, LoginSeed Referrer, LoginSeed Student, + LoginSeed Admin, string ReferralCode); }