feat: add asset access signing endpoints

This commit is contained in:
xiong
2026-07-26 14:54:50 +08:00
parent 0ff97e86cd
commit 06471151cd
9 changed files with 957 additions and 1 deletions

View File

@@ -0,0 +1,329 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Storage;
using Tiku.Domain.Commerce;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class AssetAccessService(
TikuDbContext dbContext,
IObjectStorageService objectStorageService) : IAssetAccessService
{
private static readonly TimeSpan DefaultDownloadTtl = TimeSpan.FromMinutes(15);
private static readonly TimeSpan DefaultPreviewTtl = TimeSpan.FromMinutes(10);
public Task<AssetAccessResultModel> DownloadAsync(
AssetAccessRequest request,
CancellationToken cancellationToken = default)
{
return AccessAsync(request, AssetAccessType.Download, AssetAccessDisposition.Attachment, cancellationToken);
}
public Task<AssetAccessResultModel> PreviewAsync(
AssetAccessRequest request,
CancellationToken cancellationToken = default)
{
return AccessAsync(request, AssetAccessType.Preview, AssetAccessDisposition.Inline, cancellationToken);
}
private async Task<AssetAccessResultModel> AccessAsync(
AssetAccessRequest request,
AssetAccessType accessType,
AssetAccessDisposition disposition,
CancellationToken cancellationToken)
{
var asset = await dbContext.ContentAssets
.SingleOrDefaultAsync(
item =>
item.TenantId == request.TenantId &&
item.Id == request.AssetId &&
item.Status == ContentStatus.Active,
cancellationToken);
if (asset is null)
{
throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND");
}
try
{
var access = await ResolveAccessAsync(request, asset, cancellationToken);
AssertPublishedAsset(asset);
if (accessType == AssetAccessType.Preview)
{
AssertPreviewable(asset);
}
var ttl = ResolveTtl(request, accessType, asset.Visibility);
var signedUrl = await objectStorageService.SignDownloadAsync(
new ObjectStorageDownloadSignRequest(
request.TenantId,
ToStorageProvider(asset.StorageProvider),
asset.Bucket,
accessType == AssetAccessType.Preview
? asset.PreviewObjectKey ?? asset.ObjectKey
: asset.ObjectKey,
ttl,
accessType == AssetAccessType.Preview
? asset.PreviewUrl ?? asset.CdnUrl
: asset.CdnUrl,
asset.FileName ?? asset.Title,
disposition == AssetAccessDisposition.Inline ? "inline" : "attachment"),
cancellationToken);
if (accessType == AssetAccessType.Download)
{
asset.DownloadCount++;
}
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
request,
asset,
accessType,
disposition,
AssetAccessResult.Granted,
signedUrl,
null,
access));
await dbContext.SaveChangesAsync(cancellationToken);
return new AssetAccessResultModel(
ToSummary(asset),
access,
signedUrl,
CreateWatermark(request, asset, accessType, signedUrl));
}
catch (Exception exception) when (ShouldAuditDenied(exception))
{
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
request,
asset,
accessType,
disposition,
AssetAccessResult.Denied,
null,
DenyCode(exception),
null));
await dbContext.SaveChangesAsync(cancellationToken);
throw;
}
}
private async Task<AssetAccessPrincipal> ResolveAccessAsync(
AssetAccessRequest request,
ContentAsset asset,
CancellationToken cancellationToken)
{
if (asset.Visibility == ContentVisibility.Public || asset.IsPublic)
{
return new AssetAccessPrincipal(request.UserId, IsMember: false, HasSvip: false);
}
if (asset.Visibility == ContentVisibility.Hidden)
{
throw new AssetAccessException("Asset is hidden.", "ASSET_HIDDEN");
}
if (!request.UserId.HasValue)
{
throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED");
}
var isMember = await dbContext.TenantMemberships
.AnyAsync(
membership =>
membership.TenantId == request.TenantId &&
membership.UserId == request.UserId.Value &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!isMember)
{
throw new AssetAccessException("Tenant membership is required for this asset.", "ASSET_MEMBERSHIP_REQUIRED");
}
if (asset.Visibility == ContentVisibility.Members)
{
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: false);
}
var hasSvip = await HasSvipAccessAsync(request, asset, cancellationToken);
if (!hasSvip)
{
throw new AssetAccessException("SVIP entitlement is required for this asset.", "ASSET_SVIP_REQUIRED");
}
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: true);
}
private Task<bool> HasSvipAccessAsync(
AssetAccessRequest request,
ContentAsset asset,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
return dbContext.Entitlements.AnyAsync(
entitlement =>
entitlement.TenantId == request.TenantId &&
entitlement.UserId == request.UserId!.Value &&
entitlement.EntitlementType == "svip" &&
entitlement.Status == EntitlementStatus.Active &&
entitlement.StartsAt <= now &&
(entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) &&
(entitlement.ScopeType == EntitlementScopeType.Tenant ||
(asset.RegionId != null &&
entitlement.ScopeType == EntitlementScopeType.Region &&
entitlement.ScopeId == asset.RegionId) ||
(asset.SubjectId != null &&
entitlement.ScopeType == EntitlementScopeType.Subject &&
entitlement.ScopeId == asset.SubjectId)),
cancellationToken);
}
private static void AssertPublishedAsset(ContentAsset asset)
{
if (!string.IsNullOrWhiteSpace(asset.ObjectKey) && asset.UploadStatus != AssetUploadStatus.Verified)
{
throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED");
}
if (asset.SecurityScanStatus is AssetSecurityScanStatus.Failed or AssetSecurityScanStatus.Scanning)
{
throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED");
}
}
private static void AssertPreviewable(ContentAsset asset)
{
var mimeType = asset.MimeType?.Split(';', StringSplitOptions.TrimEntries)[0].ToLowerInvariant() ?? string.Empty;
var previewable = asset.AssetType is ContentAssetType.Pdf or ContentAssetType.Image ||
mimeType == "application/pdf" ||
mimeType.StartsWith("image/", StringComparison.Ordinal);
if (!previewable)
{
throw new AssetAccessException("Asset type does not support inline preview.", "ASSET_PREVIEW_NOT_SUPPORTED");
}
}
private static TimeSpan ResolveTtl(
AssetAccessRequest request,
AssetAccessType accessType,
ContentVisibility visibility)
{
var fallback = accessType == AssetAccessType.Preview ? DefaultPreviewTtl : DefaultDownloadTtl;
var requested = request.RequestedExpiresIn ?? fallback;
var max = visibility == ContentVisibility.Public ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(15);
if (requested <= TimeSpan.Zero)
{
return fallback;
}
return requested <= max ? requested : max;
}
private static ContentAssetAccessEvent CreateAccessEvent(
AssetAccessRequest request,
ContentAsset asset,
AssetAccessType accessType,
AssetAccessDisposition disposition,
AssetAccessResult result,
ObjectStorageSignedUrl? signedUrl,
string? denyCode,
AssetAccessPrincipal? access)
{
return new ContentAssetAccessEvent
{
TenantId = request.TenantId,
AssetId = asset.Id,
UserId = request.UserId,
ActorRole = ResolveActorRole(access),
AccessType = accessType,
Visibility = asset.Visibility.ToString(),
AssetType = asset.AssetType.ToString(),
StorageProvider = asset.StorageProvider.ToString(),
Disposition = disposition,
ExpiresInSeconds = signedUrl is null ? null : (int)signedUrl.ExpiresIn.TotalSeconds,
SignatureMode = signedUrl?.SignatureMode,
Result = result,
DenyCode = denyCode,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonSerializer.SerializeToElement(new
{
asset.Title,
asset.FileName,
signedUrl?.Method
})
};
}
private static AssetAccessActorRole ResolveActorRole(AssetAccessPrincipal? access)
{
return access?.UserId.HasValue == true ? AssetAccessActorRole.Student : AssetAccessActorRole.Anonymous;
}
private static ContentAssetAccessSummary ToSummary(ContentAsset asset)
{
return new ContentAssetAccessSummary(
asset.Id,
asset.AssetType,
asset.Title,
asset.FileName,
asset.PreviewUrl,
asset.UploadStatus,
asset.SecurityScanStatus,
asset.PreviewStatus,
asset.Visibility);
}
private static JsonElement CreateWatermark(
AssetAccessRequest request,
ContentAsset asset,
AssetAccessType accessType,
ObjectStorageSignedUrl signedUrl)
{
return JsonSerializer.SerializeToElement(new
{
request.TenantId,
assetId = asset.Id,
request.UserId,
accessType = accessType.ToString(),
signedUrl.ExpiresAt,
asset.Title,
asset.FileName
});
}
private static bool ShouldAuditDenied(Exception exception)
{
return exception is AssetAccessException or ObjectStorageException or ObjectStorageNotConfiguredException;
}
private static string DenyCode(Exception exception)
{
return exception switch
{
AssetAccessException accessException => accessException.Code,
ObjectStorageException storageException => storageException.Code,
_ => "ASSET_ACCESS_DENIED"
};
}
private static string ToStorageProvider(AssetStorageProvider provider)
{
return provider switch
{
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => ObjectStorageProviders.ExternalUrl
};
}
}

View File

@@ -45,6 +45,7 @@ public static class DependencyInjection
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddScoped<IAssetAccessService, AssetAccessService>();
services.AddOptions<AliyunOssOptions>()
.Validate(
AliyunOssOptions.BeValid,