feat: add referral qrcode generation provider

This commit is contained in:
xiong
2026-07-26 20:45:14 +08:00
parent 4279795d46
commit 7c1fe7688b
11 changed files with 490 additions and 8 deletions

View File

@@ -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>();

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

View File

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

View File

@@ -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();