feat: finalize core modules and gateway

This commit is contained in:
2025-11-23 18:53:12 +08:00
parent 429d4fb747
commit ae273e510a
115 changed files with 4695 additions and 223 deletions

View File

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