diff --git a/Tiku.Application/Growth/ReferralModels.cs b/Tiku.Application/Growth/ReferralModels.cs index a271699..0d92e46 100644 --- a/Tiku.Application/Growth/ReferralModels.cs +++ b/Tiku.Application/Growth/ReferralModels.cs @@ -29,6 +29,19 @@ public sealed record ReferralQrcodeCommand( string? QrcodeUrl, JsonElement? Metadata); +public sealed record ReferralQrcodeGenerateRequest( + Guid TenantId, + Guid UserId, + string RefCode, + string Provider, + string Page, + string Scene); + +public sealed record ReferralQrcodeGenerateResult( + string QrcodeUrl, + string Provider, + 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); @@ -188,6 +201,13 @@ public interface IReferralService CancellationToken cancellationToken = default); } +public interface IReferralQrcodeGenerator +{ + Task GenerateAsync( + ReferralQrcodeGenerateRequest request, + CancellationToken cancellationToken = default); +} + public sealed class ReferralException(string message, string code) : Exception(message) { public string Code { get; } = code; diff --git a/Tiku.Application/Storage/IObjectStorageService.cs b/Tiku.Application/Storage/IObjectStorageService.cs index ab3a243..fcee771 100644 --- a/Tiku.Application/Storage/IObjectStorageService.cs +++ b/Tiku.Application/Storage/IObjectStorageService.cs @@ -19,6 +19,10 @@ public interface IObjectStorageService ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default); + Task WriteObjectAsync( + ObjectStorageWriteRequest request, + CancellationToken cancellationToken = default); + Task HeadObjectAsync( ObjectStorageHeadRequest request, CancellationToken cancellationToken = default); diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs index 7021ec5..8eed747 100644 --- a/Tiku.Application/Storage/ObjectStorageContracts.cs +++ b/Tiku.Application/Storage/ObjectStorageContracts.cs @@ -46,6 +46,18 @@ public sealed record ObjectStorageHeadRequest( long? DeclaredFileSizeBytes = null, string? DeclaredChecksumSha256 = null); +public sealed record ObjectStorageWriteRequest( + Guid TenantId, + string Provider, + string Bucket, + string ObjectKey, + string MimeType, + Stream Content, + long? FileSizeBytes, + string? ChecksumSha256 = null, + IReadOnlyDictionary? Metadata = null, + bool Upsert = true); + public sealed record ObjectStorageSignedUrl( string Provider, string? Bucket, @@ -70,6 +82,18 @@ public sealed record ObjectStorageMetadata( IReadOnlyDictionary RawHeaders, string VerificationSource); +public sealed record ObjectStorageWriteResult( + string Provider, + string Bucket, + string ObjectKey, + Uri? PublicUrl, + long? SizeBytes, + string? MimeType, + string? ChecksumSha256, + string? ETag, + IReadOnlyDictionary RawHeaders, + string VerificationSource); + public class ObjectStorageException(string message, string code) : InvalidOperationException(message) { public string Code { get; } = code; diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index d612da8..7aa1f3c 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -70,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/ReferralQrcodeGenerator.cs b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs new file mode 100644 index 0000000..0334392 --- /dev/null +++ b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs @@ -0,0 +1,229 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Senparc.Weixin.WxOpen.AdvancedAPIs.WxApp; +using Senparc.Weixin.WxOpen.Containers; +using Tiku.Application.Growth; +using Tiku.Application.Storage; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Growth; + +public sealed class ReferralQrcodeGenerator( + TikuDbContext dbContext, + IObjectStorageService objectStorageService) : IReferralQrcodeGenerator +{ + private static readonly string[] WechatMiniappProviderAliases = + [ + "wechat-miniapp", + "wechat_miniapp", + "wechat-mini", + "wechatMiniapp" + ]; + + public async Task GenerateAsync( + ReferralQrcodeGenerateRequest request, + CancellationToken cancellationToken = default) + { + var provider = NormalizeProvider(request.Provider); + return provider == "wechat-miniapp" + ? await GenerateWechatMiniappAsync(request with { Provider = provider }, cancellationToken) + : throw new ReferralException("Referral qrcode provider is not supported.", "referral_qrcode_provider_not_supported"); + } + + private async Task GenerateWechatMiniappAsync( + ReferralQrcodeGenerateRequest request, + CancellationToken cancellationToken) + { + var options = await LoadWechatMiniappOptionsAsync(request.TenantId, cancellationToken); + var imageBytes = await GenerateWechatMiniappImageAsync(options, request, cancellationToken); + var checksum = Convert.ToHexString(SHA256.HashData(imageBytes)).ToLowerInvariant(); + var objectKey = BuildObjectKey(request.TenantId, request.UserId, request.RefCode, request.Page, request.Scene); + await using var content = new MemoryStream(imageBytes, writable: false); + var storage = await objectStorageService.WriteObjectAsync( + new ObjectStorageWriteRequest( + request.TenantId, + objectStorageService.ConfiguredDefaultProvider(), + objectStorageService.ConfiguredDefaultBucket(), + objectKey, + "image/png", + content, + imageBytes.LongLength, + checksum, + new Dictionary + { + ["kind"] = "referral-qrcode", + ["provider"] = request.Provider, + ["ref-code"] = request.RefCode + }), + cancellationToken); + + if (storage.PublicUrl is null) + { + throw new ReferralException( + "Referral qrcode storage public base URL is not configured.", + "referral_qrcode_public_url_not_configured"); + } + + return new ReferralQrcodeGenerateResult( + storage.PublicUrl.ToString(), + request.Provider, + JsonSerializer.SerializeToElement(new + { + generatedBy = "senparc-weixin-wxopen", + storageProvider = storage.Provider, + storageBucket = storage.Bucket, + storageObjectKey = storage.ObjectKey, + checksumSha256 = storage.ChecksumSha256, + sizeBytes = storage.SizeBytes + })); + } + + private async Task LoadWechatMiniappOptionsAsync( + Guid tenantId, + CancellationToken cancellationToken) + { + var providers = await dbContext.TenantAuthProviders + .AsNoTracking() + .Where(entity => + entity.TenantId == tenantId && + WechatMiniappProviderAliases.Contains(entity.Provider) && + (entity.Status == TenantAuthProviderStatus.Active || + entity.Status == TenantAuthProviderStatus.Testing)) + .ToListAsync(cancellationToken); + var provider = WechatMiniappProviderAliases + .Select(alias => providers.FirstOrDefault(entity => entity.Provider == alias)) + .FirstOrDefault(entity => entity is not null); + if (provider is null) + { + throw new ReferralException( + "Wechat miniapp auth provider is not configured.", + "referral_qrcode_provider_not_configured"); + } + + var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId"); + var appSecret = GetJsonString(provider.ConfigPublic, "appSecret", "clientSecret", "secret"); + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) + { + throw new ReferralException( + "Wechat miniapp auth provider configuration is incomplete.", + "referral_qrcode_provider_not_configured"); + } + + return new WechatMiniappOptions(appId, appSecret); + } + + private static async Task GenerateWechatMiniappImageAsync( + WechatMiniappOptions options, + ReferralQrcodeGenerateRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var accessToken = await AccessTokenContainer.TryGetAccessTokenAsync( + options.AppId, + options.AppSecret, + false); + await using var imageStream = new MemoryStream(); + var result = await WxAppApi.GetWxaCodeUnlimitAsync( + accessToken, + imageStream, + request.Scene, + request.Page, + true, + "release", + 430, + false, + null!, + false, + 20); + cancellationToken.ThrowIfCancellationRequested(); + + if (result.errcode != 0) + { + throw new ReferralException( + $"Wechat miniapp qrcode generation failed: {result.errcode}.", + "referral_qrcode_wechat_failed"); + } + + var imageBytes = imageStream.ToArray(); + if (imageBytes.Length == 0) + { + throw new ReferralException( + "Wechat miniapp qrcode response was empty.", + "referral_qrcode_wechat_empty"); + } + + return imageBytes; + } + catch (ReferralException) + { + throw; + } + catch (Exception exception) + { + throw new ReferralException( + exception is HttpRequestException + ? "Wechat miniapp qrcode generation failed with network error." + : "Wechat miniapp qrcode generation failed.", + "referral_qrcode_wechat_failed"); + } + } + + private static string BuildObjectKey( + Guid tenantId, + Guid userId, + string refCode, + string page, + string scene) + { + var fingerprint = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new + { + page, + scene + }))).ToLowerInvariant()[..16]; + return $"{tenantId:N}/referral/qrcodes/{userId:N}/{NormalizePathToken(refCode)}-{fingerprint}.png"; + } + + private static string NormalizeProvider(string? provider) + { + var normalized = string.IsNullOrWhiteSpace(provider) ? "wechat-miniapp" : provider.Trim(); + return WechatMiniappProviderAliases.Contains(normalized, StringComparer.OrdinalIgnoreCase) + ? "wechat-miniapp" + : normalized; + } + + private static string NormalizePathToken(string value) + { + var chars = value + .Trim() + .Select(character => char.IsAsciiLetterOrDigit(character) ? character : '-') + .ToArray(); + var normalized = new string(chars).Trim('-'); + return string.IsNullOrWhiteSpace(normalized) ? "referral" : normalized.ToLowerInvariant(); + } + + private static string? GetJsonString(JsonElement element, params string[] names) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var name in names) + { + if (element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString()!.Trim(); + } + } + + return null; + } + + private sealed record WechatMiniappOptions(string AppId, string AppSecret); +} diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs index 98dc7fb..47fe12a 100644 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -12,7 +12,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; -public sealed class ReferralService(TikuDbContext dbContext) : IReferralService +public sealed class ReferralService( + TikuDbContext dbContext, + IReferralQrcodeGenerator qrcodeGenerator) : IReferralService { private static readonly HashSet AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) { @@ -227,13 +229,21 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService 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 providedQrcodeUrl = NormalizeOptional(command.QrcodeUrl); + var generated = providedQrcodeUrl is null + ? await qrcodeGenerator.GenerateAsync( + new ReferralQrcodeGenerateRequest(actor.TenantId, userId, refCode, provider, page, scene), + cancellationToken) + : new ReferralQrcodeGenerateResult( + providedQrcodeUrl, + provider, + JsonSerializer.SerializeToElement(new { generatedBy = "external_url" })); + var metadata = command.Metadata ?? generated.Metadata; var item = await dbContext.ReferralQrcodes .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && - entry.Provider == provider && + entry.Provider == generated.Provider && entry.Scene == scene && entry.Page == page, cancellationToken); @@ -246,14 +256,14 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService RefCode = refCode, Scene = scene, Page = page, - Provider = provider + Provider = generated.Provider }; dbContext.ReferralQrcodes.Add(item); } item.UserId = userId; item.RefCode = refCode; - item.QrcodeUrl ??= qrcodeUrl; + item.QrcodeUrl = generated.QrcodeUrl; item.Status = ReferralQrcodeStatus.Ready; item.ErrorMessage = null; item.Metadata = metadata; diff --git a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs index 70a171e..6e86b38 100644 --- a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs +++ b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Security.Cryptography; using System.Text.RegularExpressions; using AlibabaCloud.OSS.V2.Credentials; using AlibabaCloud.OSS.V2.Models; @@ -302,6 +303,90 @@ public sealed partial class AliyunOssObjectStorageService( } } + public async Task WriteObjectAsync( + ObjectStorageWriteRequest request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var provider = NormalizeProvider(request.Provider); + AssertUploadProvider(provider); + var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); + var mimeType = ValidateMimeType(request.MimeType); + var fileSizeBytes = ValidateFileSize(request.FileSizeBytes); + var checksumSha256 = NormalizeChecksum(request.ChecksumSha256); + if (request.Content.CanSeek) + { + request.Content.Position = 0; + } + + if (string.IsNullOrWhiteSpace(request.Bucket)) + { + throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); + } + + if (provider == ObjectStorageProviders.LocalDev) + { + return new ObjectStorageWriteResult( + provider, + request.Bucket, + objectKey, + BuildPublicUrl(request.Bucket, objectKey), + fileSizeBytes, + mimeType, + checksumSha256 ?? await ComputeSha256Async(request.Content, cancellationToken), + null, + new Dictionary(), + "local-dev-write-placeholder"); + } + + if (provider != ObjectStorageProviders.AliyunOss) + { + throw new ObjectStorageException( + $"{provider} does not support managed server-side writes.", + "WRITE_PROVIDER_NOT_SUPPORTED"); + } + + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (request.Metadata is not null) + { + foreach (var (key, value) in request.Metadata) + { + if (!string.IsNullOrWhiteSpace(key) && value is not null) + { + metadata[key.Trim()] = value; + } + } + } + + if (!string.IsNullOrWhiteSpace(checksumSha256)) + { + metadata["sha256"] = checksumSha256; + } + + var result = await GetClient().PutObjectAsync(new PutObjectRequest + { + Bucket = request.Bucket, + Key = objectKey, + Body = request.Content, + ContentType = mimeType, + ContentLength = fileSizeBytes, + Metadata = metadata.Count == 0 ? null : metadata, + ForbidOverwrite = !request.Upsert + }, cancellationToken: cancellationToken); + + return new ObjectStorageWriteResult( + provider, + request.Bucket, + objectKey, + BuildPublicUrl(request.Bucket, objectKey), + fileSizeBytes, + mimeType, + checksumSha256, + CleanEtag(result.ETag), + NormalizeHeaders(result.Headers), + "aliyun-oss-put-object"); + } + public void Dispose() { if (disposed) @@ -380,6 +465,23 @@ public sealed partial class AliyunOssObjectStorageService( "local-placeholder"); } + private Uri? BuildPublicUrl(string bucket, string objectKey) + { + var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return null; + } + + var path = string.Join( + '/', + bucket.Trim('/'), + objectKey + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + return new Uri($"{baseUrl}/{path}", UriKind.Absolute); + } + private Oss.Client GetClient() { ObjectDisposedException.ThrowIf(disposed, this); @@ -556,6 +658,33 @@ public sealed partial class AliyunOssObjectStorageService( private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string? NormalizeChecksum(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var normalized = value.Trim().ToLowerInvariant(); + return normalized.Length == 64 ? normalized : null; + } + + private static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) + { + if (stream.CanSeek) + { + stream.Position = 0; + } + + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + if (stream.CanSeek) + { + stream.Position = 0; + } + + return Convert.ToHexString(hash).ToLowerInvariant(); + } + [GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")] private static partial Regex SafeObjectKeyRegex(); diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 2fbba9f..13dcbcd 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Npgsql; using Tiku.Application.Commerce; using Tiku.Application.Auth; +using Tiku.Application.Growth; using Tiku.Application.Storage; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; @@ -14,6 +15,7 @@ namespace Tiku.IntegrationTests.Api; public sealed class ApiTestFactory( IWechatOAuthClient? wechatOAuthClient = null, IObjectStorageService? objectStorageService = null, + IReferralQrcodeGenerator? referralQrcodeGenerator = null, IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory { private readonly string databaseName = Guid.NewGuid().ToString(); @@ -45,6 +47,11 @@ public sealed class ApiTestFactory( services.AddSingleton(objectStorageService); } + if (referralQrcodeGenerator is not null) + { + services.AddSingleton(referralQrcodeGenerator); + } + if (paymentProviderGateway is not null) { services.AddSingleton(paymentProviderGateway); diff --git a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs index 630a076..bab1b15 100644 --- a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs @@ -301,6 +301,23 @@ public sealed class AssetAccessEndpointTests return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn)); } + public Task WriteObjectAsync( + ObjectStorageWriteRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageWriteResult( + request.Provider, + request.Bucket, + request.ObjectKey, + new Uri($"https://storage.example.test/{request.ObjectKey}"), + request.FileSizeBytes, + request.MimeType, + request.ChecksumSha256, + "fake-etag", + new Dictionary(), + "fake-write")); + } + public Task HeadObjectAsync( ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs index 286acfc..c4bcd1f 100644 --- a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -381,6 +381,23 @@ public sealed class AssetManagementEndpointTests "fake-signed-url")); } + public Task WriteObjectAsync( + ObjectStorageWriteRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageWriteResult( + request.Provider, + request.Bucket, + request.ObjectKey, + new Uri($"https://storage.example.test/{request.ObjectKey}"), + request.FileSizeBytes, + request.MimeType, + request.ChecksumSha256, + "fake-etag", + new Dictionary(), + "fake-write")); + } + public Task HeadObjectAsync( ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs index b18e807..3684875 100644 --- a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -81,7 +81,8 @@ public sealed class ReferralEndpointTests [Fact] public async Task Member_can_bind_referral_and_create_qrcode() { - await using var factory = new ApiTestFactory(); + var qrcodeGenerator = new FakeReferralQrcodeGenerator(); + await using var factory = new ApiTestFactory(referralQrcodeGenerator: qrcodeGenerator); var seed = await SeedReferralAsync(factory, includeCode: true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); @@ -106,7 +107,12 @@ public sealed class ReferralEndpointTests 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); + Assert.Equal("https://cdn.example.test/referral/qrcode.png", qrcodeItem.QrcodeUrl); + Assert.Equal(1, qrcodeGenerator.CallCount); + Assert.Equal("wechat-miniapp", qrcodeGenerator.LastRequest!.Provider); + Assert.Equal("pages/home/index", qrcodeGenerator.LastRequest.Page); + Assert.Equal($"ref={qrcodeItem.RefCode}", qrcodeGenerator.LastRequest.Scene); + Assert.Equal("fake-referral-qrcode-generator", qrcodeItem.Metadata.GetProperty("generatedBy").GetString()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -308,6 +314,24 @@ public sealed class ReferralEndpointTests return document.RootElement.Clone(); } + private sealed class FakeReferralQrcodeGenerator : IReferralQrcodeGenerator + { + public int CallCount { get; private set; } + public ReferralQrcodeGenerateRequest? LastRequest { get; private set; } + + public Task GenerateAsync( + ReferralQrcodeGenerateRequest request, + CancellationToken cancellationToken = default) + { + CallCount++; + LastRequest = request; + return Task.FromResult(new ReferralQrcodeGenerateResult( + "https://cdn.example.test/referral/qrcode.png", + request.Provider, + JsonSerializer.SerializeToElement(new { generatedBy = "fake-referral-qrcode-generator" }))); + } + } + private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); private sealed record ReferralSeed(