forked from xiongyuxing/tiku-backend.net
feat: add referral qrcode generation provider
This commit is contained in:
@@ -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<ReferralQrcodeGenerateResult> GenerateAsync(
|
||||
ReferralQrcodeGenerateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class ReferralException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
|
||||
@@ -19,6 +19,10 @@ public interface IObjectStorageService
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ObjectStorageWriteResult> WriteObjectAsync(
|
||||
ObjectStorageWriteRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -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<string, string>? Metadata = null,
|
||||
bool Upsert = true);
|
||||
|
||||
public sealed record ObjectStorageSignedUrl(
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
@@ -70,6 +82,18 @@ public sealed record ObjectStorageMetadata(
|
||||
IReadOnlyDictionary<string, string> RawHeaders,
|
||||
string VerificationSource);
|
||||
|
||||
public sealed record ObjectStorageWriteResult(
|
||||
string Provider,
|
||||
string Bucket,
|
||||
string ObjectKey,
|
||||
Uri? PublicUrl,
|
||||
long? SizeBytes,
|
||||
string? MimeType,
|
||||
string? ChecksumSha256,
|
||||
string? ETag,
|
||||
IReadOnlyDictionary<string, string> RawHeaders,
|
||||
string VerificationSource);
|
||||
|
||||
public class ObjectStorageException(string message, string code) : InvalidOperationException(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
|
||||
@@ -70,6 +70,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
services.AddScoped<IPointService, PointService>();
|
||||
services.AddScoped<IReferralQrcodeGenerator, ReferralQrcodeGenerator>();
|
||||
services.AddScoped<IReferralService, ReferralService>();
|
||||
services.AddScoped<ICrmService, CrmService>();
|
||||
services.AddScoped<ICommissionService, CommissionService>();
|
||||
|
||||
229
Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs
Normal file
229
Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs
Normal file
@@ -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<ReferralQrcodeGenerateResult> 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<ReferralQrcodeGenerateResult> 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<string, string>
|
||||
{
|
||||
["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<WechatMiniappOptions> 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<byte[]> 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);
|
||||
}
|
||||
@@ -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<string> 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;
|
||||
|
||||
@@ -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<ObjectStorageWriteResult> 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<string, string>(),
|
||||
"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<string, string>(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<string> 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();
|
||||
|
||||
|
||||
@@ -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<Program>
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -301,6 +301,23 @@ public sealed class AssetAccessEndpointTests
|
||||
return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageWriteResult> 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<string, string>(),
|
||||
"fake-write"));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -381,6 +381,23 @@ public sealed class AssetManagementEndpointTests
|
||||
"fake-signed-url"));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageWriteResult> 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<string, string>(),
|
||||
"fake-write"));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -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<TikuDbContext>();
|
||||
@@ -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<ReferralQrcodeGenerateResult> 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(
|
||||
|
||||
Reference in New Issue
Block a user