feat: add referral growth endpoints

This commit is contained in:
xiong
2026-07-26 20:18:09 +08:00
parent 4efccf8355
commit 9fff468bd7
7 changed files with 1105 additions and 0 deletions

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

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

View File

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