feat: finalize core modules and gateway
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存储应用服务抽象。
|
||||
/// </summary>
|
||||
public interface IFileStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// 通过服务端中转上传文件。
|
||||
/// </summary>
|
||||
Task<FileUploadResponse> UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成前端直传凭证(预签名上传)。
|
||||
/// </summary>
|
||||
Task<DirectUploadResponse> CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 直传凭证请求模型。
|
||||
/// </summary>
|
||||
public sealed class DirectUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建直传请求。
|
||||
/// </summary>
|
||||
public DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin)
|
||||
{
|
||||
FileType = fileType;
|
||||
FileName = fileName;
|
||||
ContentType = contentType;
|
||||
ContentLength = contentLength;
|
||||
RequestOrigin = requestOrigin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件类型。
|
||||
/// </summary>
|
||||
public UploadFileType FileType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求来源(Origin/Referer)。
|
||||
/// </summary>
|
||||
public string? RequestOrigin { get; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 直传凭证响应模型。
|
||||
/// </summary>
|
||||
public sealed class DirectUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预签名上传地址。
|
||||
/// </summary>
|
||||
public string UploadUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 表单直传所需字段(PUT 直传为空)。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> FormFields { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 预签名过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 直传完成后的访问链接(包含签名)。
|
||||
/// </summary>
|
||||
public string? DownloadUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 上传完成后的返回模型。
|
||||
/// </summary>
|
||||
public sealed class FileUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 访问 URL(已包含签名)。
|
||||
/// </summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小。
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.IO;
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件请求模型。
|
||||
/// </summary>
|
||||
public sealed class UploadFileRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建上传文件请求。
|
||||
/// </summary>
|
||||
public UploadFileRequest(
|
||||
UploadFileType fileType,
|
||||
Stream content,
|
||||
string fileName,
|
||||
string contentType,
|
||||
long contentLength,
|
||||
string? requestOrigin)
|
||||
{
|
||||
FileType = fileType;
|
||||
Content = content;
|
||||
FileName = fileName;
|
||||
ContentType = contentType;
|
||||
ContentLength = contentLength;
|
||||
RequestOrigin = requestOrigin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件分类。
|
||||
/// </summary>
|
||||
public UploadFileType FileType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求来源(Origin/Referer)。
|
||||
/// </summary>
|
||||
public string? RequestOrigin { get; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件类型,映射业务场景。
|
||||
/// </summary>
|
||||
public enum UploadFileType
|
||||
{
|
||||
/// <summary>
|
||||
/// 菜品图片。
|
||||
/// </summary>
|
||||
DishImage = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 商户 Logo。
|
||||
/// </summary>
|
||||
MerchantLogo = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 用户头像。
|
||||
/// </summary>
|
||||
UserAvatar = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 评价图片。
|
||||
/// </summary>
|
||||
ReviewImage = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 其他通用文件。
|
||||
/// </summary>
|
||||
Other = 9
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Storage.Abstractions;
|
||||
using TakeoutSaaS.Application.Storage.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 存储应用服务注册扩展。
|
||||
/// </summary>
|
||||
public static class StorageServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册文件存储应用服务。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddStorageApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IFileStorageService, FileStorageService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 上传类型解析与辅助方法。
|
||||
/// </summary>
|
||||
public static class UploadFileTypeParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 将字符串解析为上传类型。
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out UploadFileType type)
|
||||
{
|
||||
type = UploadFileType.Other;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
type = normalized switch
|
||||
{
|
||||
"dish_image" => UploadFileType.DishImage,
|
||||
"merchant_logo" => UploadFileType.MerchantLogo,
|
||||
"user_avatar" => UploadFileType.UserAvatar,
|
||||
"review_image" => UploadFileType.ReviewImage,
|
||||
_ => UploadFileType.Other
|
||||
};
|
||||
|
||||
return type != UploadFileType.Other || normalized == "other";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将上传类型转换为路径片段。
|
||||
/// </summary>
|
||||
public static string ToFolderName(this UploadFileType type) => type switch
|
||||
{
|
||||
UploadFileType.DishImage => "dishes",
|
||||
UploadFileType.MerchantLogo => "merchants",
|
||||
UploadFileType.UserAvatar => "users",
|
||||
UploadFileType.ReviewImage => "reviews",
|
||||
_ => "files"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Storage.Abstractions;
|
||||
using TakeoutSaaS.Application.Storage.Contracts;
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存储应用服务,实现上传与直传凭证生成。
|
||||
/// </summary>
|
||||
public sealed class FileStorageService(
|
||||
IStorageProviderResolver providerResolver,
|
||||
IOptionsMonitor<StorageOptions> optionsMonitor,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<FileStorageService> logger) : IFileStorageService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FileUploadResponse> UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "上传请求不能为空");
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var security = options.Security;
|
||||
ValidateOrigin(request.RequestOrigin, security);
|
||||
ValidateFileSize(request.ContentLength, security);
|
||||
|
||||
var extension = NormalizeExtension(request.FileName);
|
||||
ValidateExtension(request.FileType, extension, security);
|
||||
var contentType = NormalizeContentType(request.ContentType, extension);
|
||||
ResetStream(request.Content);
|
||||
|
||||
var objectKey = BuildObjectKey(request.FileType, extension);
|
||||
var metadata = BuildMetadata(request.FileType);
|
||||
var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes));
|
||||
var provider = providerResolver.Resolve();
|
||||
|
||||
var uploadResult = await provider.UploadAsync(
|
||||
new StorageUploadRequest(objectKey, request.Content, contentType, request.ContentLength, true, expires, metadata),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var finalUrl = AppendAntiLeechToken(uploadResult.SignedUrl ?? uploadResult.Url, objectKey, expires, security);
|
||||
logger.LogInformation("文件上传成功:{ObjectKey} ({Size} bytes)", objectKey, request.ContentLength);
|
||||
|
||||
return new FileUploadResponse
|
||||
{
|
||||
Url = finalUrl,
|
||||
FileName = Path.GetFileName(uploadResult.ObjectKey),
|
||||
FileSize = uploadResult.FileSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DirectUploadResponse> CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "直传请求不能为空");
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var security = options.Security;
|
||||
ValidateOrigin(request.RequestOrigin, security);
|
||||
ValidateFileSize(request.ContentLength, security);
|
||||
|
||||
var extension = NormalizeExtension(request.FileName);
|
||||
ValidateExtension(request.FileType, extension, security);
|
||||
var contentType = NormalizeContentType(request.ContentType, extension);
|
||||
|
||||
var objectKey = BuildObjectKey(request.FileType, extension);
|
||||
var provider = providerResolver.Resolve();
|
||||
var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes));
|
||||
|
||||
var directResult = await provider.CreateDirectUploadAsync(
|
||||
new StorageDirectUploadRequest(objectKey, contentType, request.ContentLength, expires),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var finalDownloadUrl = directResult.SignedDownloadUrl != null
|
||||
? AppendAntiLeechToken(directResult.SignedDownloadUrl, objectKey, expires, security)
|
||||
: null;
|
||||
|
||||
return new DirectUploadResponse
|
||||
{
|
||||
UploadUrl = directResult.UploadUrl,
|
||||
FormFields = directResult.FormFields,
|
||||
ExpiresAt = directResult.ExpiresAt,
|
||||
ObjectKey = directResult.ObjectKey,
|
||||
DownloadUrl = finalDownloadUrl
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验文件大小。
|
||||
/// </summary>
|
||||
private static void ValidateFileSize(long size, StorageSecurityOptions security)
|
||||
{
|
||||
if (size <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "文件内容为空");
|
||||
}
|
||||
|
||||
if (size > security.MaxFileSizeBytes)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"文件过大,最大允许 {security.MaxFileSizeBytes / 1024 / 1024}MB");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验文件后缀是否符合配置。
|
||||
/// </summary>
|
||||
private static void ValidateExtension(UploadFileType type, string extension, StorageSecurityOptions security)
|
||||
{
|
||||
var allowedImages = security.AllowedImageExtensions ?? Array.Empty<string>();
|
||||
var allowedFiles = security.AllowedFileExtensions ?? Array.Empty<string>();
|
||||
|
||||
if (type is UploadFileType.DishImage or UploadFileType.MerchantLogo or UploadFileType.UserAvatar or UploadFileType.ReviewImage)
|
||||
{
|
||||
if (!allowedImages.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"不支持的图片格式:{extension}");
|
||||
}
|
||||
}
|
||||
else if (!allowedFiles.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"不支持的文件格式:{extension}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一化文件后缀(小写,默认 .bin)。
|
||||
/// </summary>
|
||||
private static string NormalizeExtension(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
return extension.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据内容类型或后缀推断 Content-Type。
|
||||
/// </summary>
|
||||
private static string NormalizeContentType(string contentType, string extension)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
return contentType;
|
||||
}
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验请求来源是否在白名单内。
|
||||
/// </summary>
|
||||
private void ValidateOrigin(string? origin, StorageSecurityOptions security)
|
||||
{
|
||||
if (!security.EnableRefererValidation || security.AllowedReferers.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(origin))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "未授权的访问来源");
|
||||
}
|
||||
|
||||
var isAllowed = security.AllowedReferers.Any(allowed =>
|
||||
!string.IsNullOrWhiteSpace(allowed) &&
|
||||
origin.StartsWith(allowed, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isAllowed)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "访问来源未在白名单中");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成对象存储的键路径。
|
||||
/// </summary>
|
||||
private string BuildObjectKey(UploadFileType type, string extension)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantSegment = tenantId == Guid.Empty ? "platform" : tenantId.ToString("N");
|
||||
var folder = type.ToFolderName();
|
||||
var now = DateTime.UtcNow;
|
||||
var fileName = $"{Guid.NewGuid():N}{extension}";
|
||||
|
||||
return $"{tenantSegment}/{folder}/{now:yyyy/MM/dd}/{fileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组装对象元数据,便于追踪租户与用户。
|
||||
/// </summary>
|
||||
private IDictionary<string, string> BuildMetadata(UploadFileType type)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["x-meta-upload-type"] = type.ToString(),
|
||||
["x-meta-tenant-id"] = tenantProvider.GetCurrentTenantId().ToString()
|
||||
};
|
||||
|
||||
if (currentUserAccessor.IsAuthenticated)
|
||||
{
|
||||
metadata["x-meta-user-id"] = currentUserAccessor.UserId.ToString();
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置文件流的读取位置。
|
||||
/// </summary>
|
||||
private static void ResetStream(Stream stream)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为访问链接追加防盗链签名(可配合 CDN Token 验证)。
|
||||
/// </summary>
|
||||
private static string AppendAntiLeechToken(string url, string objectKey, TimeSpan expires, StorageSecurityOptions security)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(security.AntiLeechTokenSecret))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
// 若链接已包含云厂商签名参数,则避免追加自定义参数导致验签失败。
|
||||
if (url.Contains("X-Amz-Signature", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("q-sign-algorithm", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("Signature=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
var expireAt = DateTimeOffset.UtcNow.Add(expires).ToUnixTimeSeconds();
|
||||
var payload = $"{objectKey}:{expireAt}:{security.AntiLeechTokenSecret}";
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
|
||||
var token = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
var separator = url.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
return $"{url}{separator}ts={expireAt}&token={token}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user