From 06471151cd2a97d8a032583974501542d7569e0b Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 14:54:50 +0800 Subject: [PATCH] feat: add asset access signing endpoints --- Tiku.Api/Contracts/AssetAccessDtos.cs | 92 +++++ Tiku.Api/Controllers/AssetsController.cs | 97 +++++ .../Middleware/ExceptionHandlingMiddleware.cs | 37 ++ Tiku.Application/Assets/AssetAccessModels.cs | 40 +++ .../Assets/IAssetAccessService.cs | 12 + .../Assets/AssetAccessService.cs | 329 +++++++++++++++++ Tiku.Infrastructure/DependencyInjection.cs | 1 + Tiku.IntegrationTests/Api/ApiTestFactory.cs | 10 +- .../Api/AssetAccessEndpointTests.cs | 340 ++++++++++++++++++ 9 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 Tiku.Api/Contracts/AssetAccessDtos.cs create mode 100644 Tiku.Api/Controllers/AssetsController.cs create mode 100644 Tiku.Application/Assets/AssetAccessModels.cs create mode 100644 Tiku.Application/Assets/IAssetAccessService.cs create mode 100644 Tiku.Infrastructure/Assets/AssetAccessService.cs create mode 100644 Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs diff --git a/Tiku.Api/Contracts/AssetAccessDtos.cs b/Tiku.Api/Contracts/AssetAccessDtos.cs new file mode 100644 index 0000000..00aaa9c --- /dev/null +++ b/Tiku.Api/Contracts/AssetAccessDtos.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.Assets; +using Tiku.Application.Storage; +using Tiku.Domain.Content; + +namespace Tiku.Api.Contracts; + +public sealed class AssetAccessQueryDto +{ + [StringLength(100)] + public string? TenantCode { get; set; } + + [Range(1, 7200)] + public int? ExpiresInSeconds { get; set; } +} + +public sealed record AssetAccessResponseDto( + ContentAssetAccessSummaryDto Item, + AssetAccessPrincipalDto Access, + SignedStorageUrlDto Url, + JsonElement Watermark) +{ + public static AssetAccessResponseDto FromApplication(AssetAccessResultModel result) + { + return new AssetAccessResponseDto( + ContentAssetAccessSummaryDto.FromApplication(result.Item), + new AssetAccessPrincipalDto( + result.Access.UserId, + result.Access.IsMember, + result.Access.HasSvip), + SignedStorageUrlDto.FromApplication(result.Url), + result.Watermark); + } +} + +public sealed record ContentAssetAccessSummaryDto( + Guid Id, + ContentAssetType AssetType, + string? Title, + string? FileName, + string? PreviewUrl, + AssetUploadStatus UploadStatus, + AssetSecurityScanStatus SecurityScanStatus, + AssetPreviewStatus PreviewStatus, + ContentVisibility Visibility) +{ + public static ContentAssetAccessSummaryDto FromApplication(ContentAssetAccessSummary item) + { + return new ContentAssetAccessSummaryDto( + item.Id, + item.AssetType, + item.Title, + item.FileName, + item.PreviewUrl, + item.UploadStatus, + item.SecurityScanStatus, + item.PreviewStatus, + item.Visibility); + } +} + +public sealed record AssetAccessPrincipalDto( + Guid? UserId, + bool IsMember, + bool HasSvip); + +public sealed record SignedStorageUrlDto( + string Provider, + string? Bucket, + string? ObjectKey, + string Method, + string Url, + IReadOnlyDictionary Headers, + DateTimeOffset ExpiresAt, + int ExpiresInSeconds, + string SignatureMode) +{ + public static SignedStorageUrlDto FromApplication(ObjectStorageSignedUrl signedUrl) + { + return new SignedStorageUrlDto( + signedUrl.Provider, + signedUrl.Bucket, + signedUrl.ObjectKey, + signedUrl.Method, + signedUrl.Url.AbsoluteUri, + signedUrl.Headers, + signedUrl.ExpiresAt, + (int)signedUrl.ExpiresIn.TotalSeconds, + signedUrl.SignatureMode); + } +} diff --git a/Tiku.Api/Controllers/AssetsController.cs b/Tiku.Api/Controllers/AssetsController.cs new file mode 100644 index 0000000..a672627 --- /dev/null +++ b/Tiku.Api/Controllers/AssetsController.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tiku.Api.Contracts; +using Tiku.Application.Assets; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Api.Controllers; + +[ApiController] +[AllowAnonymous] +[Produces("application/json")] +[Route("api/assets")] +public sealed class AssetsController( + IAssetAccessService assetAccessService, + ICurrentTenant currentTenant, + ICurrentUser currentUser, + TikuDbContext dbContext) : ControllerBase +{ + [HttpGet("{assetId:guid}/download")] + [EndpointSummary("获取资源下载地址")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> Download( + Guid assetId, + [FromQuery] AssetAccessQueryDto query, + CancellationToken cancellationToken) + { + var result = await assetAccessService.DownloadAsync( + ToRequest(assetId, query, await ResolveTenantIdAsync(query.TenantCode, cancellationToken)), + cancellationToken); + + return Ok(AssetAccessResponseDto.FromApplication(result)); + } + + [HttpGet("{assetId:guid}/preview")] + [EndpointSummary("获取资源预览地址")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> Preview( + Guid assetId, + [FromQuery] AssetAccessQueryDto query, + CancellationToken cancellationToken) + { + var result = await assetAccessService.PreviewAsync( + ToRequest(assetId, query, await ResolveTenantIdAsync(query.TenantCode, cancellationToken)), + cancellationToken); + + return Ok(AssetAccessResponseDto.FromApplication(result)); + } + + private AssetAccessRequest ToRequest(Guid assetId, AssetAccessQueryDto query, Guid tenantId) + { + return new AssetAccessRequest( + tenantId, + assetId, + currentUser.UserId, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + query.ExpiresInSeconds.HasValue + ? TimeSpan.FromSeconds(query.ExpiresInSeconds.Value) + : null); + } + + private async Task 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(); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index dfac6ae..2df902d 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Controllers; +using Tiku.Application.Assets; using Tiku.Application.Auth; +using Tiku.Application.Storage; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.QuestionBanks; @@ -75,6 +77,28 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is AssetAccessException assetAccessException) + { + await WriteProblemAsync( + context, + assetAccessException.Message, + AssetAccessStatusCode(assetAccessException.Code), + assetAccessException.Code.ToLowerInvariant()); + return; + } + + if (exception is ObjectStorageException storageException) + { + await WriteProblemAsync( + context, + storageException.Message, + storageException is ObjectStorageNotConfiguredException + ? StatusCodes.Status503ServiceUnavailable + : StatusCodes.Status400BadRequest, + storageException.Code.ToLowerInvariant()); + return; + } + logger.LogError(exception, "Unhandled API exception"); var problem = new ProblemDetails @@ -132,4 +156,17 @@ public sealed class ExceptionHandlingMiddleware( context.Response.StatusCode = status; await context.Response.WriteAsJsonAsync(problem); } + + private static int AssetAccessStatusCode(string code) + { + return code switch + { + "ASSET_NOT_FOUND" => StatusCodes.Status404NotFound, + "AUTH_REQUIRED" => StatusCodes.Status401Unauthorized, + "ASSET_HIDDEN" or "ASSET_MEMBERSHIP_REQUIRED" or "ASSET_SVIP_REQUIRED" => StatusCodes.Status403Forbidden, + "ASSET_UPLOAD_NOT_VERIFIED" or "ASSET_SECURITY_SCAN_NOT_PASSED" => StatusCodes.Status409Conflict, + "ASSET_PREVIEW_NOT_SUPPORTED" => StatusCodes.Status400BadRequest, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Assets/AssetAccessModels.cs b/Tiku.Application/Assets/AssetAccessModels.cs new file mode 100644 index 0000000..a05e8ef --- /dev/null +++ b/Tiku.Application/Assets/AssetAccessModels.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Tiku.Application.Storage; +using Tiku.Domain.Content; + +namespace Tiku.Application.Assets; + +public sealed record AssetAccessRequest( + Guid TenantId, + Guid AssetId, + Guid? UserId, + string? IpAddress, + string? UserAgent, + TimeSpan? RequestedExpiresIn = null); + +public sealed record AssetAccessResultModel( + ContentAssetAccessSummary Item, + AssetAccessPrincipal Access, + ObjectStorageSignedUrl Url, + JsonElement Watermark); + +public sealed record ContentAssetAccessSummary( + Guid Id, + ContentAssetType AssetType, + string? Title, + string? FileName, + string? PreviewUrl, + AssetUploadStatus UploadStatus, + AssetSecurityScanStatus SecurityScanStatus, + AssetPreviewStatus PreviewStatus, + ContentVisibility Visibility); + +public sealed record AssetAccessPrincipal( + Guid? UserId, + bool IsMember, + bool HasSvip); + +public sealed class AssetAccessException(string message, string code) : InvalidOperationException(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/Assets/IAssetAccessService.cs b/Tiku.Application/Assets/IAssetAccessService.cs new file mode 100644 index 0000000..06ec58f --- /dev/null +++ b/Tiku.Application/Assets/IAssetAccessService.cs @@ -0,0 +1,12 @@ +namespace Tiku.Application.Assets; + +public interface IAssetAccessService +{ + Task DownloadAsync( + AssetAccessRequest request, + CancellationToken cancellationToken = default); + + Task PreviewAsync( + AssetAccessRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Assets/AssetAccessService.cs b/Tiku.Infrastructure/Assets/AssetAccessService.cs new file mode 100644 index 0000000..8452e5e --- /dev/null +++ b/Tiku.Infrastructure/Assets/AssetAccessService.cs @@ -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 DownloadAsync( + AssetAccessRequest request, + CancellationToken cancellationToken = default) + { + return AccessAsync(request, AssetAccessType.Download, AssetAccessDisposition.Attachment, cancellationToken); + } + + public Task PreviewAsync( + AssetAccessRequest request, + CancellationToken cancellationToken = default) + { + return AccessAsync(request, AssetAccessType.Preview, AssetAccessDisposition.Inline, cancellationToken); + } + + private async Task 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 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 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 + }; + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index d8b72cd..d70edeb 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -45,6 +45,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions() .Validate( AliyunOssOptions.BeValid, diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 833b8eb..850be9e 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; using Tiku.Application.Auth; +using Tiku.Application.Storage; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; -public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) : WebApplicationFactory +public sealed class ApiTestFactory( + IWechatOAuthClient? wechatOAuthClient = null, + IObjectStorageService? objectStorageService = null) : WebApplicationFactory { private readonly string databaseName = Guid.NewGuid().ToString(); @@ -34,6 +37,11 @@ public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) { services.AddSingleton(wechatOAuthClient); } + + if (objectStorageService is not null) + { + services.AddSingleton(objectStorageService); + } }); } diff --git a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs new file mode 100644 index 0000000..630a076 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs @@ -0,0 +1,340 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Application.Storage; +using Tiku.Domain.Common; +using Tiku.Domain.Commerce; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AssetAccessEndpointTests +{ + [Fact] + public async Task Public_asset_can_be_downloaded_anonymously_and_is_audited() + { + var tenantId = Guid.NewGuid(); + var assetId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + await factory.SeedAsync( + Tenant(tenantId, "master"), + PublicAsset(tenantId, assetId)); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, dbContext.ContentAssets.Single(asset => asset.Id == assetId).DownloadCount); + Assert.Contains(dbContext.ContentAssetAccessEvents, item => + item.AssetId == assetId && + item.Result == AssetAccessResult.Granted && + item.AccessType == AssetAccessType.Download); + } + + [Fact] + public async Task Members_asset_requires_authentication() + { + var tenantId = Guid.NewGuid(); + var assetId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new ContentAsset + { + Id = assetId, + TenantId = tenantId, + Title = "会员资料", + Visibility = ContentVisibility.Members, + StorageProvider = AssetStorageProvider.LocalDev, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/members.pdf", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.Passed + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("auth_required", body.RootElement.GetProperty("code").GetString()); + } + + [Fact] + public async Task Members_asset_can_be_downloaded_by_active_member() + { + var tenantId = Guid.NewGuid(); + var assetId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + var seed = await SeedLoginUserAsync(factory, tenantId); + await factory.SeedAsync( + new ContentAsset + { + Id = assetId, + TenantId = tenantId, + Title = "会员资料", + Visibility = ContentVisibility.Members, + StorageProvider = AssetStorageProvider.LocalDev, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/members.pdf", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.Passed + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var response = await client.GetAsync($"/api/assets/{assetId}/download"); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(seed.UserId, body.RootElement.GetProperty("access").GetProperty("userId").GetGuid()); + } + + [Fact] + public async Task Svip_asset_requires_active_entitlement() + { + var tenantId = Guid.NewGuid(); + var assetId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + var seed = await SeedLoginUserAsync(factory, tenantId); + await factory.SeedAsync( + new ContentAsset + { + Id = assetId, + TenantId = tenantId, + Title = "SVIP 资料", + Visibility = ContentVisibility.Svip, + StorageProvider = AssetStorageProvider.LocalDev, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/svip.pdf", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.Passed + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var deniedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); + await factory.SeedAsync(new Entitlement + { + TenantId = tenantId, + UserId = seed.UserId, + EntitlementType = "svip", + ScopeType = EntitlementScopeType.Tenant, + Status = EntitlementStatus.Active, + StartsAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + using var grantedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); + + Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, grantedResponse.StatusCode); + } + + [Fact] + public async Task Preview_rejects_non_previewable_assets() + { + var tenantId = Guid.NewGuid(); + var assetId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new ContentAsset + { + Id = assetId, + TenantId = tenantId, + Title = "压缩包", + Visibility = ContentVisibility.Public, + StorageProvider = AssetStorageProvider.LocalDev, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/archive.zip", + MimeType = "application/zip", + AssetType = ContentAssetType.Package, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.Passed + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/assets/{assetId}/preview?tenantCode=master"); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("asset_preview_not_supported", body.RootElement.GetProperty("code").GetString()); + } + + private static ContentAsset PublicAsset(Guid tenantId, Guid assetId) + { + return new ContentAsset + { + Id = assetId, + TenantId = tenantId, + Title = "公开资料", + FileName = "download.pdf", + Visibility = ContentVisibility.Public, + StorageProvider = AssetStorageProvider.LocalDev, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/download.pdf", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.Passed + }; + } + + private static Tenant Tenant(Guid id, string slug) + { + return new Tenant + { + Id = id, + Slug = slug, + Name = slug, + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }; + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( + ApiTestFactory factory, + Guid tenantId) + { + var userId = Guid.NewGuid(); + var phone = "13800000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + Tenant(tenantId, tenantId.ToString("N")), + new User + { + Id = userId, + Phone = phone, + Name = "Test User" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task LoginAsync( + HttpClient client, + (Guid TenantId, Guid UserId, string Phone) seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } + + private sealed class FakeObjectStorageService : IObjectStorageService + { + public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev; + public string ConfiguredDefaultBucket() => "tenant-assets"; + public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev; + public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey; + public string ValidateMimeType(string mimeType) => mimeType; + public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes; + public void AssertUploadProvider(string provider) { } + public void AssertWritableLocation(StorageAssetLocation location) { } + + public Task SignUploadAsync( + ObjectStorageUploadSignRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Signed("PUT", request.Bucket, request.ObjectKey, request.ExpiresIn)); + } + + public Task SignDownloadAsync( + ObjectStorageDownloadSignRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn)); + } + + public Task HeadObjectAsync( + ObjectStorageHeadRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageMetadata( + request.Provider, + request.Bucket, + request.ObjectKey, + Exists: true, + request.DeclaredFileSizeBytes, + request.DeclaredMimeType, + request.DeclaredChecksumSha256, + null, + null, + new Dictionary(), + "fake")); + } + + private static ObjectStorageSignedUrl Signed( + string method, + string? bucket, + string? objectKey, + TimeSpan expiresIn) + { + return new ObjectStorageSignedUrl( + ObjectStorageProviders.LocalDev, + bucket, + objectKey, + method, + new Uri("https://storage.example.test/download.pdf"), + new Dictionary(), + DateTimeOffset.UtcNow.Add(expiresIn), + expiresIn, + "fake-signed-url"); + } + } +}