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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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