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,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<string, string> 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);
}
}

View File

@@ -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<AssetAccessResponseDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AssetAccessResponseDto>> 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<AssetAccessResponseDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AssetAccessResponseDto>> 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<Guid> 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();
}
}

View File

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