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,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace TakeoutSaaS.Application.Messaging.Abstractions;
/// <summary>
/// 领域事件发布抽象。
/// </summary>
public interface IEventPublisher
{
/// <summary>
/// 发布领域事件。
/// </summary>
Task PublishAsync<TEvent>(string routingKey, TEvent @event, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.Messaging;
/// <summary>
/// 事件路由键常量。
/// </summary>
public static class EventRoutingKeys
{
/// <summary>
/// 订单创建事件路由键。
/// </summary>
public const string OrderCreated = "orders.created";
/// <summary>
/// 支付成功事件路由键。
/// </summary>
public const string PaymentSucceeded = "payments.succeeded";
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.Messaging.Events;
/// <summary>
/// 订单创建事件。
/// </summary>
public sealed class OrderCreatedEvent
{
/// <summary>
/// 订单标识。
/// </summary>
public Guid OrderId { get; init; }
/// <summary>
/// 订单编号。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 实付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 所属租户。
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.Messaging.Events;
/// <summary>
/// 支付成功事件。
/// </summary>
public sealed class PaymentSucceededEvent
{
/// <summary>
/// 订单标识。
/// </summary>
public Guid OrderId { get; init; }
/// <summary>
/// 支付流水号。
/// </summary>
public string PaymentNo { get; init; } = string.Empty;
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 所属租户。
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime PaidAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.Messaging.Abstractions;
using TakeoutSaaS.Application.Messaging.Services;
namespace TakeoutSaaS.Application.Messaging.Extensions;
/// <summary>
/// 消息模块应用层注册。
/// </summary>
public static class MessagingServiceCollectionExtensions
{
/// <summary>
/// 注册事件发布器。
/// </summary>
public static IServiceCollection AddMessagingApplication(this IServiceCollection services)
{
services.AddScoped<IEventPublisher, EventPublisher>();
return services;
}
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Application.Messaging.Abstractions;
using TakeoutSaaS.Module.Messaging.Abstractions;
namespace TakeoutSaaS.Application.Messaging.Services;
/// <summary>
/// 事件发布适配器,封装应用层到 MQ 的发布。
/// </summary>
public sealed class EventPublisher(IMessagePublisher messagePublisher) : IEventPublisher
{
/// <inheritdoc />
public Task PublishAsync<TEvent>(string routingKey, TEvent @event, CancellationToken cancellationToken = default)
=> messagePublisher.PublishAsync(routingKey, @event, cancellationToken);
}

View File

@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Application.Sms.Contracts;
namespace TakeoutSaaS.Application.Sms.Abstractions;
/// <summary>
/// 短信验证码服务抽象。
/// </summary>
public interface IVerificationCodeService
{
/// <summary>
/// 发送验证码。
/// </summary>
Task<SendVerificationCodeResponse> SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 校验验证码。
/// </summary>
Task<bool> VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,34 @@
using TakeoutSaaS.Module.Sms;
namespace TakeoutSaaS.Application.Sms.Contracts;
/// <summary>
/// 发送验证码请求。
/// </summary>
public sealed class SendVerificationCodeRequest
{
/// <summary>
/// 创建发送请求。
/// </summary>
public SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null)
{
PhoneNumber = phoneNumber;
Scene = scene;
Provider = provider;
}
/// <summary>
/// 手机号(支持 +86 前缀或纯 11 位)。
/// </summary>
public string PhoneNumber { get; }
/// <summary>
/// 业务场景(如 login/register/reset
/// </summary>
public string Scene { get; }
/// <summary>
/// 指定服务商,未指定则使用默认配置。
/// </summary>
public SmsProviderKind? Provider { get; }
}

View File

@@ -0,0 +1,19 @@
using System;
namespace TakeoutSaaS.Application.Sms.Contracts;
/// <summary>
/// 发送验证码响应。
/// </summary>
public sealed class SendVerificationCodeResponse
{
/// <summary>
/// 过期时间。
/// </summary>
public DateTimeOffset ExpiresAt { get; set; }
/// <summary>
/// 请求标识。
/// </summary>
public string? RequestId { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.Sms.Contracts;
/// <summary>
/// 校验验证码请求。
/// </summary>
public sealed class VerifyVerificationCodeRequest
{
/// <summary>
/// 创建校验请求。
/// </summary>
public VerifyVerificationCodeRequest(string phoneNumber, string scene, string code)
{
PhoneNumber = phoneNumber;
Scene = scene;
Code = code;
}
/// <summary>
/// 手机号。
/// </summary>
public string PhoneNumber { get; }
/// <summary>
/// 业务场景。
/// </summary>
public string Scene { get; }
/// <summary>
/// 填写的验证码。
/// </summary>
public string Code { get; }
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.Sms.Abstractions;
using TakeoutSaaS.Application.Sms.Options;
using TakeoutSaaS.Application.Sms.Services;
namespace TakeoutSaaS.Application.Sms.Extensions;
/// <summary>
/// 短信应用服务注册扩展。
/// </summary>
public static class SmsServiceCollectionExtensions
{
/// <summary>
/// 注册短信验证码应用服务。
/// </summary>
public static IServiceCollection AddSmsApplication(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<VerificationCodeOptions>()
.Bind(configuration.GetSection("Sms:VerificationCode"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddScoped<IVerificationCodeService, VerificationCodeService>();
return services;
}
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.Sms.Options;
/// <summary>
/// 验证码发送配置。
/// </summary>
public sealed class VerificationCodeOptions
{
/// <summary>
/// 验证码位数,默认 6。
/// </summary>
[Range(4, 10)]
public int CodeLength { get; set; } = 6;
/// <summary>
/// 过期时间(分钟)。
/// </summary>
[Range(1, 60)]
public int ExpireMinutes { get; set; } = 5;
/// <summary>
/// 发送冷却时间(秒),用于防止频繁请求。
/// </summary>
[Range(10, 300)]
public int CooldownSeconds { get; set; } = 60;
/// <summary>
/// 缓存前缀。
/// </summary>
[Required]
public string CachePrefix { get; set; } = "sms:code";
}

View File

@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.Sms.Abstractions;
using TakeoutSaaS.Application.Sms.Contracts;
using TakeoutSaaS.Application.Sms.Options;
using TakeoutSaaS.Module.Sms.Abstractions;
using TakeoutSaaS.Module.Sms.Models;
using TakeoutSaaS.Module.Sms.Options;
using TakeoutSaaS.Module.Sms;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Sms.Services;
/// <summary>
/// 短信验证码服务实现。
/// </summary>
public sealed class VerificationCodeService(
ISmsSenderResolver senderResolver,
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
IOptionsMonitor<VerificationCodeOptions> codeOptionsMonitor,
ITenantProvider tenantProvider,
IDistributedCache cache,
ILogger<VerificationCodeService> logger) : IVerificationCodeService
{
/// <inheritdoc />
public async Task<SendVerificationCodeResponse> SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.PhoneNumber))
{
throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空");
}
if (string.IsNullOrWhiteSpace(request.Scene))
{
throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空");
}
var smsOptions = smsOptionsMonitor.CurrentValue;
var codeOptions = codeOptionsMonitor.CurrentValue;
var templateCode = ResolveTemplate(request.Scene, smsOptions);
var phone = NormalizePhoneNumber(request.PhoneNumber);
var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N");
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
var cooldownKey = $"{cacheKey}:cooldown";
await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false);
var code = GenerateCode(codeOptions.CodeLength);
var variables = new Dictionary<string, string> { { "code", code } };
var sender = senderResolver.Resolve(request.Provider);
var smsRequest = new SmsSendRequest(phone, templateCode, variables, smsOptions.DefaultSignName);
var smsResult = await sender.SendAsync(smsRequest, cancellationToken).ConfigureAwait(false);
if (!smsResult.Success)
{
throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}");
}
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes);
await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions
{
AbsoluteExpiration = expiresAt
}, cancellationToken).ConfigureAwait(false);
await cache.SetStringAsync(cooldownKey, "1", new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(codeOptions.CooldownSeconds)
}, cancellationToken).ConfigureAwait(false);
logger.LogInformation("发送验证码成功Phone:{Phone} Scene:{Scene} Tenant:{Tenant}", phone, request.Scene, tenantKey);
return new SendVerificationCodeResponse
{
ExpiresAt = expiresAt,
RequestId = smsResult.RequestId
};
}
/// <inheritdoc />
public async Task<bool> VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Code))
{
return false;
}
var codeOptions = codeOptionsMonitor.CurrentValue;
var phone = NormalizePhoneNumber(request.PhoneNumber);
var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N");
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(cachedCode))
{
return false;
}
var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal);
if (success)
{
await cache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
return success;
}
private static string ResolveTemplate(string scene, SmsOptions options)
{
if (options.SceneTemplates.TryGetValue(scene, out var template) && !string.IsNullOrWhiteSpace(template))
{
return template;
}
throw new BusinessException(ErrorCodes.BadRequest, $"未配置场景 {scene} 的短信模板");
}
private static string NormalizePhoneNumber(string phone)
{
var trimmed = phone.Trim();
return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}";
}
private static string GenerateCode(int length)
{
var buffer = new byte[length];
RandomNumberGenerator.Fill(buffer);
var builder = new StringBuilder(length);
foreach (var b in buffer)
{
builder.Append((b % 10).ToString());
}
return builder.ToString()[..length];
}
private async Task EnsureCooldownAsync(string cooldownKey, int cooldownSeconds, CancellationToken cancellationToken)
{
var existing = await cache.GetStringAsync(cooldownKey, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(existing))
{
throw new BusinessException(ErrorCodes.BadRequest, "请求过于频繁,请稍后再试");
}
}
}

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

View File

@@ -7,8 +7,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj" />
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
</ItemGroup>