feat: finalize core modules and gateway
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user