@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
|
||||
namespace TakeoutSaaS.Module.Dictionary.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典模块服务扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryModuleExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块应用层与基础设施。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDictionaryModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDictionaryApplication();
|
||||
services.AddDictionaryInfrastructure(configuration);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息发布抽象。
|
||||
/// </summary>
|
||||
public interface IMessagePublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 发布消息到指定路由键。
|
||||
/// </summary>
|
||||
Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息订阅抽象。
|
||||
/// </summary>
|
||||
public interface IMessageSubscriber : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅指定队列与路由键,处理后返回是否消费成功。
|
||||
/// </summary>
|
||||
Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Messaging.Serialization;
|
||||
using TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息队列模块注册扩展。
|
||||
/// </summary>
|
||||
public static class MessagingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 RabbitMQ 发布/订阅能力。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var rabbitMqSection = configuration.GetSection("RabbitMQ");
|
||||
if (!rabbitMqSection.Exists())
|
||||
{
|
||||
services.AddSingleton<IMessagePublisher, NoOpMessagePublisher>();
|
||||
services.AddSingleton<IMessageSubscriber, NoOpMessageSubscriber>();
|
||||
return services;
|
||||
}
|
||||
|
||||
// 1. (空行后) 存在 RabbitMQ 配置时才启用真实 MQ 能力(启动时验证配置完整性)
|
||||
services.AddOptions<RabbitMqOptions>()
|
||||
.Bind(rabbitMqSection)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<JsonMessageSerializer>();
|
||||
services.AddSingleton<RabbitMqConnectionFactory>();
|
||||
services.AddSingleton<IMessagePublisher, RabbitMqMessagePublisher>();
|
||||
services.AddSingleton<IMessageSubscriber, RabbitMqMessageSubscriber>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Options;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 连接与交换机配置。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 主机名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// 端口。
|
||||
/// </summary>
|
||||
[Range(1, 65535)]
|
||||
public int Port { get; set; } = 5672;
|
||||
|
||||
/// <summary>
|
||||
/// 用户名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Username { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Password { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// 虚拟主机。
|
||||
/// </summary>
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
|
||||
/// <summary>
|
||||
/// 默认交换机名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Exchange { get; set; } = "takeout.events";
|
||||
|
||||
/// <summary>
|
||||
/// 交换机类型,默认 topic。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "topic";
|
||||
|
||||
/// <summary>
|
||||
/// 消费预取数量。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public ushort PrefetchCount { get; set; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 消息 JSON 序列化器。
|
||||
/// </summary>
|
||||
public sealed class JsonMessageSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions DefaultOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 序列化消息。
|
||||
/// </summary>
|
||||
public byte[] Serialize<T>(T message) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, DefaultOptions));
|
||||
|
||||
/// <summary>
|
||||
/// 反序列化消息。
|
||||
/// </summary>
|
||||
public T? Deserialize<T>(byte[] body) => JsonSerializer.Deserialize<T>(body, DefaultOptions);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 空实现消息发布器:用于未配置 RabbitMQ 的开发/测试场景,避免启动依赖外部 MQ。
|
||||
/// </summary>
|
||||
public sealed class NoOpMessagePublisher(ILogger<NoOpMessagePublisher> logger) : IMessagePublisher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"未配置 RabbitMQ,已跳过消息发布:RoutingKey={RoutingKey} MessageType={MessageType}",
|
||||
routingKey,
|
||||
typeof(T).FullName ?? typeof(T).Name);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 空实现消息订阅器:用于未配置 RabbitMQ 的开发/测试场景。
|
||||
/// </summary>
|
||||
public sealed class NoOpMessageSubscriber(ILogger<NoOpMessageSubscriber> logger) : IMessageSubscriber
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogWarning("未配置 RabbitMQ,消息订阅被禁用:Queue={Queue} RoutingKey={RoutingKey}", queue, routingKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 1. (空行后) 释放资源(NoOp 实现无实际资源)
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
logger.LogDebug("NoOpMessageSubscriber 已释放。");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 连接工厂封装。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqConnectionFactory(IOptionsMonitor<RabbitMqOptions> optionsMonitor)
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建连接。
|
||||
/// </summary>
|
||||
public async Task<IConnection> CreateConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = options.Host,
|
||||
Port = options.Port,
|
||||
UserName = options.Username,
|
||||
Password = options.Password,
|
||||
VirtualHost = options.VirtualHost
|
||||
};
|
||||
|
||||
return await factory.CreateConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Messaging.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 消息发布实现。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor<RabbitMqOptions> optionsMonitor, JsonMessageSerializer serializer, ILogger<RabbitMqMessagePublisher> logger)
|
||||
: IMessagePublisher, IAsyncDisposable
|
||||
{
|
||||
private IConnection? _connection;
|
||||
private IChannel? _channel;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 确保通道可用
|
||||
await EnsureChannelAsync(cancellationToken);
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available.");
|
||||
// 2. 声明交换机
|
||||
await channel.ExchangeDeclareAsync(options.Exchange, options.ExchangeType, durable: true, autoDelete: false, arguments: null, noWait: false, cancellationToken).ConfigureAwait(false);
|
||||
// 3. 序列化消息并设置属性
|
||||
var body = serializer.Serialize(message);
|
||||
var props = new BasicProperties();
|
||||
props.ContentType = "application/json";
|
||||
props.DeliveryMode = DeliveryModes.Persistent;
|
||||
props.MessageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// 4. 发布消息
|
||||
await channel.BasicPublishAsync(options.Exchange, routingKey, mandatory: false, basicProperties: props, body: body, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey);
|
||||
}
|
||||
|
||||
private async Task EnsureChannelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_channel != null && _channel.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_channel = await _connection.CreateChannelAsync(new CreateChannelOptions(false, false, null, null), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 RabbitMQ 资源。
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
return CloseAsync();
|
||||
}
|
||||
|
||||
private async ValueTask CloseAsync()
|
||||
{
|
||||
if (_channel != null)
|
||||
{
|
||||
await _channel.CloseAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_connection != null)
|
||||
{
|
||||
await _connection.CloseAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Messaging.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 消费者实现。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor<RabbitMqOptions> optionsMonitor, JsonMessageSerializer serializer, ILogger<RabbitMqMessageSubscriber> logger)
|
||||
: IMessageSubscriber
|
||||
{
|
||||
private IConnection? _connection;
|
||||
private IChannel? _channel;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 确保通道可用
|
||||
await EnsureChannelAsync(cancellationToken);
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available.");
|
||||
|
||||
// 2. 声明交换机、队列及绑定
|
||||
await channel.ExchangeDeclareAsync(options.Exchange, options.ExchangeType, durable: true, autoDelete: false, arguments: null, noWait: false, cancellationToken).ConfigureAwait(false);
|
||||
await channel.QueueDeclareAsync(queue, durable: true, exclusive: false, autoDelete: false, arguments: null, noWait: false, cancellationToken).ConfigureAwait(false);
|
||||
await channel.QueueBindAsync(queue, options.Exchange, routingKey, arguments: null, noWait: false, cancellationToken).ConfigureAwait(false);
|
||||
await channel.BasicQosAsync(0, options.PrefetchCount, global: false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. 设置消费者回调
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, ea) =>
|
||||
{
|
||||
var message = serializer.Deserialize<T>(ea.Body.ToArray());
|
||||
if (message == null)
|
||||
{
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
success = await handler(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "处理消息失败:{RoutingKey}", ea.RoutingKey);
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 开始消费
|
||||
await channel.BasicConsumeAsync(queue, autoAck: false, consumer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureChannelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_channel != null && _channel.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_channel = await _connection.CreateChannelAsync(new CreateChannelOptions(false, false, null, null), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_channel != null)
|
||||
{
|
||||
await _channel.CloseAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_connection != null)
|
||||
{
|
||||
await _connection.CloseAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 周期性任务注册抽象。
|
||||
/// </summary>
|
||||
public interface IRecurringJobRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册所有预设的周期性任务。
|
||||
/// </summary>
|
||||
Task RegisterAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
using TakeoutSaaS.Module.Scheduler.HostedServices;
|
||||
using TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 调度模块注册扩展(默认 Hangfire)。
|
||||
/// </summary>
|
||||
public static class SchedulerServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册调度模块。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSchedulerModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<SchedulerOptions>()
|
||||
.Bind(configuration.GetSection("Scheduler"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<SubscriptionAutomationOptions>()
|
||||
.Bind(configuration.GetSection("Scheduler:SubscriptionAutomation"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<BillingAutomationOptions>()
|
||||
.Bind(configuration.GetSection("Scheduler:BillingAutomation"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHangfire((serviceProvider, config) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
||||
config
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(storage =>
|
||||
{
|
||||
storage.UseNpgsqlConnection(options.ConnectionString);
|
||||
});
|
||||
});
|
||||
|
||||
services.AddHangfireServer((serviceProvider, options) =>
|
||||
{
|
||||
var scheduler = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
||||
options.WorkerCount = scheduler.WorkerCount ?? options.WorkerCount;
|
||||
});
|
||||
|
||||
services.AddSingleton<IRecurringJobRegistrar, RecurringJobRegistrar>();
|
||||
services.AddHostedService<RecurringJobHostedService>();
|
||||
|
||||
services.AddScoped<OrderTimeoutJob>();
|
||||
services.AddScoped<CouponExpireJob>();
|
||||
services.AddScoped<LogCleanupJob>();
|
||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||
services.AddScoped<SubscriptionExpiryCheckJob>();
|
||||
services.AddScoped<SubscriptionAutoRenewalJob>();
|
||||
services.AddScoped<BillingOverdueProcessJob>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用 Hangfire Dashboard(默认关闭,可通过配置开启)。
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseSchedulerDashboard(this IApplicationBuilder app, IConfiguration configuration)
|
||||
{
|
||||
var options = configuration.GetSection("Scheduler").Get<SchedulerOptions>();
|
||||
if (options is { DashboardEnabled: true })
|
||||
{
|
||||
app.UseHangfireDashboard(options.DashboardPath);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时注册周期性任务的宿主服务。
|
||||
/// </summary>
|
||||
public sealed class RecurringJobHostedService(IRecurringJobRegistrar registrar, ILogger<RecurringJobHostedService> logger) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await registrar.RegisterAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("调度任务已注册");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 账单逾期标记任务:将超过到期日的待支付账单标记为逾期。
|
||||
/// </summary>
|
||||
public sealed class BillingOverdueProcessJob(
|
||||
IMediator mediator,
|
||||
IOptionsMonitor<BillingAutomationOptions> optionsMonitor,
|
||||
ILogger<BillingOverdueProcessJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行逾期账单标记。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 读取配置并执行逾期处理
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var updatedCount = await mediator.Send(new ProcessOverdueBillingsCommand());
|
||||
|
||||
// 2. 记录执行结果
|
||||
logger.LogInformation(
|
||||
"定时任务:逾期账单标记完成,更新 {UpdatedCount} 条(Cron={Cron})",
|
||||
updatedCount,
|
||||
options.OverdueBillingProcessCron);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 门店营业状态自动切换任务。
|
||||
/// </summary>
|
||||
public sealed class BusinessStatusAutoSwitchJob(
|
||||
IStoreSchedulerService schedulerService,
|
||||
ILogger<BusinessStatusAutoSwitchJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行自动切换。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 执行自动切换
|
||||
var updated = await schedulerService.AutoSwitchBusinessStatusAsync(DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
// 2. (空行后) 记录执行结果
|
||||
logger.LogInformation("定时任务:门店营业状态自动切换执行完成,更新 {UpdatedCount} 家门店", updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券过期处理任务(占位实现)。
|
||||
/// </summary>
|
||||
public sealed class CouponExpireJob(ILogger<CouponExpireJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行优惠券过期清理。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:处理已过期优惠券(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 日志清理任务(占位实现)。
|
||||
/// </summary>
|
||||
public sealed class LogCleanupJob(ILogger<LogCleanupJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行日志清理。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:清理历史日志(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 订单超时取消任务(占位,后续接入订单服务)。
|
||||
/// </summary>
|
||||
public sealed class OrderTimeoutJob(ILogger<OrderTimeoutJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行超时订单检查。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质过期检查任务。
|
||||
/// </summary>
|
||||
public sealed class QualificationExpiryCheckJob(
|
||||
IStoreSchedulerService schedulerService,
|
||||
ILogger<QualificationExpiryCheckJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行资质过期检查。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 执行资质过期检查
|
||||
var updated = await schedulerService.CheckQualificationExpiryAsync(DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
// 2. (空行后) 记录执行结果
|
||||
logger.LogInformation("定时任务:门店资质过期检查执行完成,更新 {UpdatedCount} 家门店", updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅自动续费任务:为即将到期且开启自动续费的订阅生成续费账单。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionAutoRenewalJob(
|
||||
IMediator mediator,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
|
||||
ILogger<SubscriptionAutoRenewalJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行自动续费账单生成。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 读取配置
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
// 2. (空行后) 获取需要处理的租户列表(排除系统租户)
|
||||
var tenants = await tenantRepository.SearchAsync(null, null, CancellationToken.None);
|
||||
var targets = tenants.Where(x => x.Id > 0).ToList();
|
||||
|
||||
// 3. (空行后) 按租户逐个执行自动续费
|
||||
var candidateCount = 0;
|
||||
var createdBillCount = 0;
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
try
|
||||
{
|
||||
foreach (var tenant in targets)
|
||||
{
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "scheduler");
|
||||
try
|
||||
{
|
||||
var result = await mediator.Send(new ProcessAutoRenewalCommand
|
||||
{
|
||||
RenewalDaysBeforeExpiry = options.AutoRenewalDaysBeforeExpiry
|
||||
});
|
||||
|
||||
candidateCount += result.CandidateCount;
|
||||
createdBillCount += result.CreatedBillCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "定时任务:自动续费执行失败 TenantId={TenantId}", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
|
||||
// 4. (空行后) 记录执行结果
|
||||
logger.LogInformation(
|
||||
"定时任务:自动续费处理完成,处理租户 {TenantCount},候选 {CandidateCount},创建账单 {CreatedBillCount}",
|
||||
targets.Count,
|
||||
candidateCount,
|
||||
createdBillCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查任务:到期进入宽限期,宽限期到期自动暂停。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckJob(
|
||||
IMediator mediator,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
|
||||
ILogger<SubscriptionExpiryCheckJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行订阅到期检查。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 读取配置
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
// 2. (空行后) 获取需要处理的租户列表(排除系统租户)
|
||||
var tenants = await tenantRepository.SearchAsync(null, null, CancellationToken.None);
|
||||
var targets = tenants.Where(x => x.Id > 0).ToList();
|
||||
|
||||
// 3. (空行后) 按租户逐个执行到期处理
|
||||
var enteredGracePeriodCount = 0;
|
||||
var suspendedCount = 0;
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
try
|
||||
{
|
||||
foreach (var tenant in targets)
|
||||
{
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "scheduler");
|
||||
try
|
||||
{
|
||||
var result = await mediator.Send(new ProcessSubscriptionExpiryCommand
|
||||
{
|
||||
GracePeriodDays = options.GracePeriodDays
|
||||
});
|
||||
|
||||
enteredGracePeriodCount += result.EnteredGracePeriodCount;
|
||||
suspendedCount += result.SuspendedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "定时任务:订阅到期检查执行失败 TenantId={TenantId}", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
|
||||
// 4. (空行后) 记录执行结果
|
||||
logger.LogInformation(
|
||||
"定时任务:订阅到期检查完成,处理租户 {TenantCount},进入宽限期 {EnteredGracePeriodCount},暂停 {SuspendedCount}",
|
||||
targets.Count,
|
||||
enteredGracePeriodCount,
|
||||
suspendedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅续费提醒任务:到期前 7/3/1 天发送站内提醒。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionRenewalReminderJob(
|
||||
IMediator mediator,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
|
||||
ILogger<SubscriptionRenewalReminderJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行续费提醒扫描与发送。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 读取配置
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
// 2. (空行后) 获取需要处理的租户列表(排除系统租户)
|
||||
var tenants = await tenantRepository.SearchAsync(null, null, CancellationToken.None);
|
||||
var targets = tenants.Where(x => x.Id > 0).ToList();
|
||||
|
||||
// 3. (空行后) 按租户逐个执行续费提醒
|
||||
var candidateCount = 0;
|
||||
var createdReminderCount = 0;
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
try
|
||||
{
|
||||
foreach (var tenant in targets)
|
||||
{
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "scheduler");
|
||||
try
|
||||
{
|
||||
var result = await mediator.Send(new ProcessRenewalRemindersCommand
|
||||
{
|
||||
ReminderDaysBeforeExpiry = options.ReminderDaysBeforeExpiry
|
||||
});
|
||||
|
||||
candidateCount += result.CandidateCount;
|
||||
createdReminderCount += result.CreatedReminderCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "定时任务:续费提醒执行失败 TenantId={TenantId}", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
|
||||
// 4. (空行后) 记录执行结果
|
||||
logger.LogInformation(
|
||||
"定时任务:续费提醒处理完成,处理租户 {TenantCount},候选 {CandidateCount},创建 {CreatedReminderCount}",
|
||||
targets.Count,
|
||||
candidateCount,
|
||||
createdReminderCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 账单自动化相关配置(逾期标记等)。
|
||||
/// </summary>
|
||||
public sealed class BillingAutomationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 逾期账单标记任务 Cron 表达式(Hangfire)。
|
||||
/// 默认每 10 分钟执行一次。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string OverdueBillingProcessCron { get; set; } = "*/10 * * * *";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 调度模块配置。
|
||||
/// </summary>
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Hangfire 存储使用的连接字符串。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 工作线程数,默认根据 CPU 计算。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? WorkerCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用 Dashboard(默认 false,待 AdminUI 接入)。
|
||||
/// </summary>
|
||||
public bool DashboardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard 路径。
|
||||
/// </summary>
|
||||
public string DashboardPath { get; set; } = "/hangfire";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅自动化相关配置(续费提醒、自动续费、宽限期处理)。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionAutomationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动续费任务执行小时(UTC)。
|
||||
/// </summary>
|
||||
[Range(0, 23)]
|
||||
public int AutoRenewalExecuteHourUtc { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费:到期前 N 天生成续费账单。
|
||||
/// </summary>
|
||||
[Range(0, 365)]
|
||||
public int AutoRenewalDaysBeforeExpiry { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒任务执行小时(UTC)。
|
||||
/// </summary>
|
||||
[Range(0, 23)]
|
||||
public int RenewalReminderExecuteHourUtc { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒:到期前 N 天发送提醒。
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public int[] ReminderDaysBeforeExpiry { get; set; } = [7, 3, 1];
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查任务执行小时(UTC)。
|
||||
/// </summary>
|
||||
[Range(0, 23)]
|
||||
public int SubscriptionExpiryCheckExecuteHourUtc { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 宽限期天数。
|
||||
/// </summary>
|
||||
[Range(0, 365)]
|
||||
public int GracePeriodDays { get; set; } = 7;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
using TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
using TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 周期性任务注册器。
|
||||
/// </summary>
|
||||
public sealed class RecurringJobRegistrar(
|
||||
IOptionsMonitor<SubscriptionAutomationOptions> subscriptionAutomationOptions,
|
||||
IOptionsMonitor<BillingAutomationOptions> billingAutomationOptions)
|
||||
: IRecurringJobRegistrar
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task RegisterAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 业务占位任务(示例)
|
||||
RecurringJob.AddOrUpdate<OrderTimeoutJob>("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *");
|
||||
RecurringJob.AddOrUpdate<CouponExpireJob>("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *");
|
||||
RecurringJob.AddOrUpdate<LogCleanupJob>("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");
|
||||
|
||||
// 2. 订阅自动化任务(自动续费、续费提醒、到期进入宽限期)
|
||||
var options = subscriptionAutomationOptions.CurrentValue;
|
||||
RecurringJob.AddOrUpdate<SubscriptionAutoRenewalJob>(
|
||||
"subscriptions.auto-renewal",
|
||||
job => job.ExecuteAsync(),
|
||||
$"0 {options.AutoRenewalExecuteHourUtc} * * *");
|
||||
RecurringJob.AddOrUpdate<SubscriptionRenewalReminderJob>(
|
||||
"subscriptions.renewal-reminder",
|
||||
job => job.ExecuteAsync(),
|
||||
$"0 {options.RenewalReminderExecuteHourUtc} * * *");
|
||||
RecurringJob.AddOrUpdate<SubscriptionExpiryCheckJob>(
|
||||
"subscriptions.expiry-check",
|
||||
job => job.ExecuteAsync(),
|
||||
$"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *");
|
||||
|
||||
// 3. 账单自动化任务(逾期标记)
|
||||
var billingOptions = billingAutomationOptions.CurrentValue;
|
||||
RecurringJob.AddOrUpdate<BillingOverdueProcessJob>(
|
||||
"billings.overdue-process",
|
||||
job => job.ExecuteAsync(),
|
||||
billingOptions.OverdueBillingProcessCron);
|
||||
|
||||
// 4. (空行后) 门店管理自动化任务
|
||||
RecurringJob.AddOrUpdate<BusinessStatusAutoSwitchJob>(
|
||||
"stores.business-status-auto-switch",
|
||||
job => job.ExecuteAsync(),
|
||||
"*/1 * * * *");
|
||||
RecurringJob.AddOrUpdate<QualificationExpiryCheckJob>(
|
||||
"stores.qualification-expiry-check",
|
||||
job => job.ExecuteAsync(),
|
||||
"0 2 * * *");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送抽象。
|
||||
/// </summary>
|
||||
public interface ISmsSender
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务商类型。
|
||||
/// </summary>
|
||||
SmsProviderKind Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送短信。
|
||||
/// </summary>
|
||||
Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Module.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商解析器。
|
||||
/// </summary>
|
||||
public interface ISmsSenderResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定服务商的发送器。
|
||||
/// </summary>
|
||||
ISmsSender Resolve(SmsProviderKind? provider = null);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
using TakeoutSaaS.Module.Sms.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信模块 DI 注册扩展。
|
||||
/// </summary>
|
||||
public static class SmsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册短信模块(包含腾讯云、阿里云实现)。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSmsModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<SmsOptions>()
|
||||
.Bind(configuration.GetSection("Sms"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient(nameof(TencentSmsSender));
|
||||
services.AddHttpClient(nameof(AliyunSmsSender));
|
||||
|
||||
services.AddSingleton<ISmsSender, TencentSmsSender>();
|
||||
services.AddSingleton<ISmsSender, AliyunSmsSender>();
|
||||
services.AddSingleton<ISmsSenderResolver, SmsSenderResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
42
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs
Normal file
42
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送请求。
|
||||
/// </summary>
|
||||
public sealed class SmsSendRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化短信发送请求。
|
||||
/// </summary>
|
||||
/// <param name="phoneNumber">目标手机号码(含国家码,如 +86xxxxxxxxxxx)。</param>
|
||||
/// <param name="templateCode">模版编号。</param>
|
||||
/// <param name="variables">模版变量。</param>
|
||||
/// <param name="signName">短信签名。</param>
|
||||
public SmsSendRequest(string phoneNumber, string templateCode, IDictionary<string, string> variables, string? signName = null)
|
||||
{
|
||||
PhoneNumber = phoneNumber;
|
||||
TemplateCode = templateCode;
|
||||
Variables = new Dictionary<string, string>(variables);
|
||||
SignName = signName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标手机号。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 模版编号。
|
||||
/// </summary>
|
||||
public string TemplateCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 模版变量。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Variables { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选的签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; }
|
||||
}
|
||||
22
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs
Normal file
22
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送结果。
|
||||
/// </summary>
|
||||
public sealed class SmsSendResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否发送成功。
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务商返回的请求标识。
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信配置。
|
||||
/// </summary>
|
||||
public sealed class AliyunSmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// AccessKeyId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// AccessKeySecret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeySecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务域名。
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "dysmsapi.aliyuncs.com";
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地域 ID。
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "cn-hangzhou";
|
||||
}
|
||||
41
src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs
Normal file
41
src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 短信模块配置。
|
||||
/// </summary>
|
||||
public sealed class SmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认服务商,默认为腾讯云。
|
||||
/// </summary>
|
||||
public SmsProviderKind Provider { get; set; } = SmsProviderKind.Tencent;
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? DefaultSignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用模拟发送(仅日志,不实际调用),方便开发环境。
|
||||
/// </summary>
|
||||
public bool UseMock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public TencentSmsOptions Tencent { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AliyunSmsOptions Aliyun { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 场景与模板映射(如 login: TEMPLATE_ID)。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> SceneTemplates { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信配置。
|
||||
/// </summary>
|
||||
public sealed class TencentSmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// SecretId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SecretKey。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用 SdkAppId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SdkAppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认地域。
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "ap-guangzhou";
|
||||
|
||||
/// <summary>
|
||||
/// 接口域名。
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "https://sms.tencentcloudapi.com";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信发送实现(简化版,占位可扩展正式签名流程)。
|
||||
/// </summary>
|
||||
public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<AliyunSmsSender> logger)
|
||||
: ISmsSender
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SmsProviderKind Provider => SmsProviderKind.Aliyun;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (options.UseMock)
|
||||
{
|
||||
logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode);
|
||||
return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" });
|
||||
}
|
||||
// 预留 HttpClient,便于后续接入阿里云正式签名请求
|
||||
using var httpClient = httpClientFactory.CreateClient(nameof(AliyunSmsSender));
|
||||
|
||||
// 占位:保留待接入阿里云正式签名流程,当前返回未实现。
|
||||
logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。");
|
||||
return Task.FromResult(new SmsSendResult { Success = false, Message = "Aliyun SMS not enabled" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商解析器。
|
||||
/// </summary>
|
||||
public sealed class SmsSenderResolver(IOptionsMonitor<SmsOptions> optionsMonitor, IEnumerable<ISmsSender> senders) : ISmsSenderResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<SmsProviderKind, ISmsSender> _map = senders.ToDictionary(x => x.Provider);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISmsSender Resolve(SmsProviderKind? provider = null)
|
||||
{
|
||||
var key = provider ?? optionsMonitor.CurrentValue.Provider;
|
||||
if (_map.TryGetValue(key, out var sender))
|
||||
{
|
||||
return sender;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"未注册短信服务商:{key}");
|
||||
}
|
||||
}
|
||||
140
src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs
Normal file
140
src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信发送实现(TC3-HMAC 签名)。
|
||||
/// </summary>
|
||||
public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<TencentSmsSender> logger)
|
||||
: ISmsSender
|
||||
{
|
||||
private const string Service = "sms";
|
||||
private const string Action = "SendSms";
|
||||
private const string Version = "2021-01-11";
|
||||
|
||||
/// <inheritdoc />
|
||||
public SmsProviderKind Provider => SmsProviderKind.Tencent;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取配置并处理 Mock
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (options.UseMock)
|
||||
{
|
||||
logger.LogInformation("Mock 发送短信到 {Phone}, Template:{Template}, Vars:{Vars}", request.PhoneNumber, request.TemplateCode, JsonSerializer.Serialize(request.Variables));
|
||||
return new SmsSendResult { Success = true, Message = "Mocked" };
|
||||
}
|
||||
|
||||
// 2. 构建请求负载与签名所需字段
|
||||
var tencent = options.Tencent;
|
||||
var payload = BuildPayload(request, tencent);
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var date = DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
var host = new Uri(tencent.Endpoint).Host;
|
||||
var canonicalRequest = BuildCanonicalRequest(payload, host, tencent.Endpoint.StartsWith("https", StringComparison.OrdinalIgnoreCase));
|
||||
var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date);
|
||||
var signature = Sign(stringToSign, tencent.SecretKey, date);
|
||||
|
||||
// 3. 构建 HTTP 请求
|
||||
using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender));
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
httpRequest.Headers.Add("Host", host);
|
||||
httpRequest.Headers.Add("X-TC-Action", Action);
|
||||
httpRequest.Headers.Add("X-TC-Version", Version);
|
||||
httpRequest.Headers.Add("X-TC-Timestamp", timestamp.ToString(CultureInfo.InvariantCulture));
|
||||
httpRequest.Headers.Add("X-TC-Region", tencent.Region);
|
||||
httpRequest.Headers.Add("Authorization",
|
||||
$"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}");
|
||||
|
||||
// 4. 发送请求并读取响应
|
||||
var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("腾讯云短信发送失败:{Status} {Content}", response.StatusCode, content);
|
||||
return new SmsSendResult { Success = false, Message = content };
|
||||
}
|
||||
|
||||
// 5. 解析响应
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement.GetProperty("Response");
|
||||
var status = root.GetProperty("SendStatusSet")[0];
|
||||
var code = status.GetProperty("Code").GetString();
|
||||
var message = status.GetProperty("Message").GetString();
|
||||
var requestId = root.GetProperty("RequestId").GetString();
|
||||
|
||||
var success = string.Equals(code, "Ok", StringComparison.OrdinalIgnoreCase);
|
||||
return new SmsSendResult
|
||||
{
|
||||
Success = success,
|
||||
RequestId = requestId,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPayload(SmsSendRequest request, TencentSmsOptions options)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
PhoneNumberSet = new[] { request.PhoneNumber },
|
||||
SmsSdkAppId = options.SdkAppId,
|
||||
SignName = request.SignName ?? options.SignName,
|
||||
TemplateId = request.TemplateCode,
|
||||
TemplateParamSet = request.Variables.Values.ToArray()
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static string BuildCanonicalRequest(string payload, string host, bool useHttps)
|
||||
{
|
||||
_ = useHttps;
|
||||
var hashedPayload = HashSha256(payload);
|
||||
var canonicalHeaders = $"content-type:application/json\nhost:{host}\n";
|
||||
return $"POST\n/\n\n{canonicalHeaders}\ncontent-type;host\n{hashedPayload}";
|
||||
}
|
||||
|
||||
private static string BuildStringToSign(string canonicalRequest, long timestamp, string date)
|
||||
{
|
||||
var hashedRequest = HashSha256(canonicalRequest);
|
||||
return $"TC3-HMAC-SHA256\n{timestamp}\n{date}/{Service}/tc3_request\n{hashedRequest}";
|
||||
}
|
||||
|
||||
private static string Sign(string stringToSign, string secretKey, string date)
|
||||
{
|
||||
static byte[] HmacSha256(byte[] key, string msg) => new HMACSHA256(key).ComputeHash(Encoding.UTF8.GetBytes(msg));
|
||||
|
||||
var secretDate = HmacSha256(Encoding.UTF8.GetBytes($"TC3{secretKey}"), date);
|
||||
var secretService = HmacSha256(secretDate, Service);
|
||||
var secretSigning = HmacSha256(secretService, "tc3_request");
|
||||
var signatureBytes = new HMACSHA256(secretSigning).ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
|
||||
return Convert.ToHexString(signatureBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashSha256(string raw)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
var builder = new StringBuilder(bytes.Length * 2);
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
17
src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs
Normal file
17
src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Module.Sms;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商类型。
|
||||
/// </summary>
|
||||
public enum SmsProviderKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 腾讯云短信。
|
||||
/// </summary>
|
||||
Tencent = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信。
|
||||
/// </summary>
|
||||
Aliyun = 2
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储提供商统一抽象。
|
||||
/// </summary>
|
||||
public interface IObjectStorageProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前提供商类型。
|
||||
/// </summary>
|
||||
StorageProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件到对象存储。
|
||||
/// </summary>
|
||||
Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名直传参数(PUT 或表单直传)。
|
||||
/// </summary>
|
||||
Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成带过期时间的访问链接。
|
||||
/// </summary>
|
||||
Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成公共访问地址(可结合 CDN)。
|
||||
/// </summary>
|
||||
string BuildPublicUrl(string objectKey);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 存储提供商解析器,用于按需选择具体实现。
|
||||
/// </summary>
|
||||
public interface IStorageProviderResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据配置解析出可用的存储提供商。
|
||||
/// </summary>
|
||||
/// <param name="provider">目标提供商类型,空则使用默认配置。</param>
|
||||
/// <returns>对应的存储提供商。</returns>
|
||||
IObjectStorageProvider Resolve(StorageProviderKind? provider = null);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
using TakeoutSaaS.Module.Storage.Providers;
|
||||
using TakeoutSaaS.Module.Storage.Services;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 存储模块服务注册扩展。
|
||||
/// </summary>
|
||||
public static class StorageServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册存储模块所需的提供商与配置。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<StorageOptions>()
|
||||
.Bind(configuration.GetSection("Storage"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IObjectStorageProvider, TencentCosStorageProvider>();
|
||||
services.AddSingleton<IObjectStorageProvider, QiniuKodoStorageProvider>();
|
||||
services.AddSingleton<IObjectStorageProvider, AliyunOssStorageProvider>();
|
||||
services.AddSingleton<IStorageProviderResolver, StorageProviderResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 直传(预签名上传)请求参数。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化请求。
|
||||
/// </remarks>
|
||||
/// <param name="objectKey">对象键。</param>
|
||||
/// <param name="contentType">内容类型。</param>
|
||||
/// <param name="contentLength">内容长度。</param>
|
||||
/// <param name="expires">签名有效期。</param>
|
||||
public sealed class StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires)
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; } = objectKey;
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; } = contentType;
|
||||
|
||||
/// <summary>
|
||||
/// 内容长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; } = contentLength;
|
||||
|
||||
/// <summary>
|
||||
/// 签名有效期。
|
||||
/// </summary>
|
||||
public TimeSpan Expires { get; } = expires;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 直传(预签名上传)结果。
|
||||
/// </summary>
|
||||
public sealed class StorageDirectUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 预签名上传地址(PUT 上传或表单地址)。
|
||||
/// </summary>
|
||||
public string UploadUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 直传附加字段(如表单直传所需字段),PUT 方式为空。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> FormFields { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 预签名过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 上传成功后可选的签名下载地址。
|
||||
/// </summary>
|
||||
public string? SignedDownloadUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储上传请求参数。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化上传请求。
|
||||
/// </remarks>
|
||||
/// <param name="objectKey">对象键(含路径)。</param>
|
||||
/// <param name="content">文件流。</param>
|
||||
/// <param name="contentType">内容类型。</param>
|
||||
/// <param name="contentLength">内容长度。</param>
|
||||
/// <param name="generateSignedUrl">是否返回签名访问链接。</param>
|
||||
/// <param name="signedUrlExpires">签名有效期。</param>
|
||||
/// <param name="metadata">附加元数据。</param>
|
||||
public sealed class StorageUploadRequest(
|
||||
string objectKey,
|
||||
Stream content,
|
||||
string contentType,
|
||||
long contentLength,
|
||||
bool generateSignedUrl,
|
||||
TimeSpan signedUrlExpires,
|
||||
IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// 对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; } = objectKey;
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream Content { get; } = content;
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; } = contentType;
|
||||
|
||||
/// <summary>
|
||||
/// 内容长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; } = contentLength;
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要签名访问链接。
|
||||
/// </summary>
|
||||
public bool GenerateSignedUrl { get; } = generateSignedUrl;
|
||||
|
||||
/// <summary>
|
||||
/// 签名有效期。
|
||||
/// </summary>
|
||||
public TimeSpan SignedUrlExpires { get; } = signedUrlExpires;
|
||||
|
||||
/// <summary>
|
||||
/// 元数据集合。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; } = metadata == null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(metadata);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 上传结果信息。
|
||||
/// </summary>
|
||||
public sealed class StorageUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 可访问的 URL(可能已包含签名)。
|
||||
/// </summary>
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 带过期时间的签名 URL(若生成)。
|
||||
/// </summary>
|
||||
public string? SignedUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小。
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 访问配置。
|
||||
/// </summary>
|
||||
public sealed class AliyunOssOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 访问密钥 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 访问密钥 Secret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeySecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint,如 https://oss-cn-hangzhou.aliyuncs.com。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Url]
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标存储桶名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CDN 加速域名(可选)。
|
||||
/// </summary>
|
||||
[Url]
|
||||
public string? CdnBaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认使用 HTTPS。
|
||||
/// </summary>
|
||||
public bool UseHttps { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 七牛云 Kodo S3 兼容网关配置。
|
||||
/// </summary>
|
||||
public sealed class QiniuKodoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// AccessKey。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SecretKey。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 绑定的空间名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下载域名(CDN 域名或测试域名),用于生成访问链接。
|
||||
/// </summary>
|
||||
[Url]
|
||||
public string? DownloadDomain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// S3 兼容网关 Endpoint(如 https://s3-cn-south-1.qiniucs.com),为空则使用官方默认。
|
||||
/// </summary>
|
||||
[Url]
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用 HTTPS。
|
||||
/// </summary>
|
||||
public bool UseHttps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 直传或下载时默认有效期(分钟),未设置时使用全局安全配置。
|
||||
/// </summary>
|
||||
[Range(1, 24 * 60)]
|
||||
public int? SignedUrlExpirationMinutes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 存储模块的统一配置项,决定默认提供商与全局安全策略。
|
||||
/// </summary>
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认使用的存储提供商。
|
||||
/// </summary>
|
||||
public StorageProviderKind Provider { get; set; } = StorageProviderKind.TencentCos;
|
||||
|
||||
/// <summary>
|
||||
/// CDN 访问域名(可选),若配置则优先使用 CDN 域名生成访问地址。
|
||||
/// </summary>
|
||||
[Url]
|
||||
public string? CdnBaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public TencentCosOptions TencentCos { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 七牛云 Kodo 配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public QiniuKodoOptions QiniuKodo { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AliyunOssOptions AliyunOss { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 存储安全策略配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public StorageSecurityOptions Security { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 文件安全与防盗链相关配置。
|
||||
/// </summary>
|
||||
public sealed class StorageSecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个文件最大尺寸(字节),默认 10MB。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// 允许的图片后缀名白名单。
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] AllowedImageExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif" };
|
||||
|
||||
/// <summary>
|
||||
/// 允许的通用文件后缀名白名单。
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] AllowedFileExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" };
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名有效期(分钟),用于生成带过期时间的访问链接。
|
||||
/// </summary>
|
||||
[Range(1, 24 * 60)]
|
||||
public int DefaultUrlExpirationMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用来源校验(防盗链),为空则不校验。
|
||||
/// </summary>
|
||||
public bool EnableRefererValidation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 允许的 Referer/Origin 前缀列表,用于限制上传接口调用来源。
|
||||
/// </summary>
|
||||
public string[] AllowedReferers { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 针对 CDN 防盗链的额外签名密钥(可选),用于生成二次校验签名。
|
||||
/// </summary>
|
||||
public string? AntiLeechTokenSecret { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 访问配置。
|
||||
/// </summary>
|
||||
public sealed class TencentCosOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// SecretId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SecretKey。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 存储地域(如 ap-guangzhou)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Region { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 存储桶名称(含 AppId,如 takeout-bucket-123456)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// COS 自定义域名或 API Endpoint(可选),未配置则根据 Region 生成默认域名。
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CDN 域名(可选),用于生成加速访问地址。
|
||||
/// </summary>
|
||||
[Url]
|
||||
public string? CdnBaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用 HTTPS。
|
||||
/// </summary>
|
||||
public bool UseHttps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制使用 PathStyle 访问,COS 默认可使用虚拟主机形式。
|
||||
/// </summary>
|
||||
public bool ForcePathStyle { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Aliyun.OSS;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储提供商实现。
|
||||
/// </summary>
|
||||
public sealed class AliyunOssStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor) : IObjectStorageProvider, IDisposable
|
||||
{
|
||||
private OssClient? _client;
|
||||
private bool _disposed;
|
||||
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public StorageProviderKind Kind => StorageProviderKind.AliyunOss;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 准备元数据
|
||||
var options = CurrentOptions;
|
||||
var metadata = new ObjectMetadata
|
||||
{
|
||||
ContentLength = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
|
||||
foreach (var kv in request.Metadata)
|
||||
{
|
||||
metadata.UserMetadata[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
// Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。
|
||||
// 2. 上传对象
|
||||
await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// 3. 生成签名或公有 URL
|
||||
var signedUrl = request.GenerateSignedUrl
|
||||
? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
// 4. 返回上传结果
|
||||
return new StorageUploadResult
|
||||
{
|
||||
ObjectKey = request.ObjectKey,
|
||||
Url = signedUrl ?? BuildPublicUrl(request.ObjectKey),
|
||||
SignedUrl = signedUrl,
|
||||
FileSize = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 计算过期时间并生成直传/下载链接
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires);
|
||||
var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType);
|
||||
var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null);
|
||||
|
||||
// 2. 返回直传参数
|
||||
var result = new StorageDirectUploadResult
|
||||
{
|
||||
UploadUrl = uploadUrl,
|
||||
FormFields = new Dictionary<string, string>(),
|
||||
ExpiresAt = expiresAt,
|
||||
ObjectKey = request.ObjectKey,
|
||||
SignedDownloadUrl = downloadUrl
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成预签名下载 URL
|
||||
var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildPublicUrl(string objectKey)
|
||||
{
|
||||
var cdn = CurrentOptions.AliyunOss.CdnBaseUrl ?? CurrentOptions.CdnBaseUrl;
|
||||
if (!string.IsNullOrWhiteSpace(cdn))
|
||||
{
|
||||
return $"{cdn!.TrimEnd('/')}/{objectKey}";
|
||||
}
|
||||
|
||||
var endpoint = CurrentOptions.AliyunOss.Endpoint.TrimEnd('/');
|
||||
var scheme = CurrentOptions.AliyunOss.UseHttps ? "https" : "http";
|
||||
// Endpoint 可能已包含协议,若没有则补充。
|
||||
if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
endpoint = $"{scheme}://{endpoint}";
|
||||
}
|
||||
|
||||
return $"{endpoint}/{CurrentOptions.AliyunOss.Bucket}/{objectKey}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传对象到 OSS。
|
||||
/// </summary>
|
||||
private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = EnsureClient();
|
||||
// SDK 无异步则封装为 Task
|
||||
await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名 URL。
|
||||
/// </summary>
|
||||
private string GeneratePresignedUrl(string objectKey, TimeSpan expires, SignHttpMethod method, string? contentType)
|
||||
{
|
||||
var request = new GeneratePresignedUriRequest(CurrentOptions.AliyunOss.Bucket, objectKey, method)
|
||||
{
|
||||
Expiration = DateTime.Now.Add(expires)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.ContentType = contentType;
|
||||
}
|
||||
|
||||
var uri = EnsureClient().GeneratePresignedUri(request);
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建或复用 OSS 客户端。
|
||||
/// </summary>
|
||||
private OssClient EnsureClient()
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
return _client;
|
||||
}
|
||||
|
||||
var options = CurrentOptions.AliyunOss;
|
||||
_client = new OssClient(options.Endpoint, options.AccessKeyId, options.AccessKeySecret);
|
||||
return _client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 七牛云 Kodo(S3 兼容网关)存储提供商。
|
||||
/// </summary>
|
||||
public sealed class QiniuKodoStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor)
|
||||
: S3StorageProviderBase
|
||||
{
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override StorageProviderKind Kind => StorageProviderKind.QiniuKodo;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string Bucket => CurrentOptions.QiniuKodo.Bucket;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.Endpoint)
|
||||
? $"{(CurrentOptions.QiniuKodo.UseHttps ? "https" : "http")}://s3.qiniucs.com"
|
||||
: CurrentOptions.QiniuKodo.Endpoint!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AccessKey => CurrentOptions.QiniuKodo.AccessKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string SecretKey => CurrentOptions.QiniuKodo.SecretKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool UseHttps => CurrentOptions.QiniuKodo.UseHttps;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ForcePathStyle => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.DownloadDomain)
|
||||
? CurrentOptions.QiniuKodo.DownloadDomain
|
||||
: CurrentOptions.CdnBaseUrl;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override TimeSpan SignedUrlExpiry
|
||||
{
|
||||
get
|
||||
{
|
||||
var minutes = CurrentOptions.QiniuKodo.SignedUrlExpirationMinutes
|
||||
?? CurrentOptions.Security.DefaultUrlExpirationMinutes;
|
||||
return TimeSpan.FromMinutes(Math.Max(1, minutes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 AWS S3 SDK 的通用存储提供商基类,可复用到 COS 与 Kodo 等兼容实现。
|
||||
/// </summary>
|
||||
public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposable
|
||||
{
|
||||
private IAmazonS3? _client;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract StorageProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标桶名称。
|
||||
/// </summary>
|
||||
protected abstract string Bucket { get; }
|
||||
|
||||
/// <summary>
|
||||
/// S3 服务端点,需包含协议。
|
||||
/// </summary>
|
||||
protected abstract string ServiceUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问凭证 ID。
|
||||
/// </summary>
|
||||
protected abstract string AccessKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问凭证密钥。
|
||||
/// </summary>
|
||||
protected abstract string SecretKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用 HTTPS。
|
||||
/// </summary>
|
||||
protected abstract bool UseHttps { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制 PathStyle 访问。
|
||||
/// </summary>
|
||||
protected abstract bool ForcePathStyle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CDN 域名(可选)。
|
||||
/// </summary>
|
||||
protected abstract string? CdnBaseUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名有效期。
|
||||
/// </summary>
|
||||
protected abstract TimeSpan SignedUrlExpiry { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 0. 兜底重置流位置,避免上游读取导致内容缺失
|
||||
if (request.Content.CanSeek)
|
||||
{
|
||||
request.Content.Position = 0;
|
||||
}
|
||||
|
||||
// 1. 构建上传请求
|
||||
var putRequest = new PutObjectRequest
|
||||
{
|
||||
BucketName = Bucket,
|
||||
Key = request.ObjectKey,
|
||||
InputStream = request.Content,
|
||||
AutoCloseStream = false,
|
||||
ContentType = request.ContentType,
|
||||
DisableDefaultChecksumValidation = true,
|
||||
UseChunkEncoding = false,
|
||||
DisablePayloadSigning = true
|
||||
};
|
||||
|
||||
// 1.1 显式设置 Content-Length,避免 S3 SDK 对兼容网关使用 aws-chunked 导致对象内容被写入“chunk-signature”头而损坏
|
||||
if (request.ContentLength > 0)
|
||||
{
|
||||
putRequest.Headers.ContentLength = request.ContentLength;
|
||||
}
|
||||
|
||||
foreach (var kv in request.Metadata)
|
||||
{
|
||||
putRequest.Metadata[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
// 2. 执行上传
|
||||
await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. 根据需要生成签名 URL
|
||||
var signedUrl = request.GenerateSignedUrl
|
||||
? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires)
|
||||
: null;
|
||||
|
||||
// 4. 返回上传结果
|
||||
return new StorageUploadResult
|
||||
{
|
||||
ObjectKey = request.ObjectKey,
|
||||
Url = signedUrl ?? BuildPublicUrl(request.ObjectKey),
|
||||
SignedUrl = signedUrl,
|
||||
FileSize = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 计算过期时间并生成直传 URL
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires);
|
||||
var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType);
|
||||
var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires);
|
||||
|
||||
// 2. 返回直传参数
|
||||
var result = new StorageDirectUploadResult
|
||||
{
|
||||
UploadUrl = uploadUrl,
|
||||
FormFields = new Dictionary<string, string>(),
|
||||
ExpiresAt = expiresAt,
|
||||
ObjectKey = request.ObjectKey,
|
||||
SignedDownloadUrl = signedDownload
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成下载签名 URL
|
||||
var url = GenerateSignedUrl(objectKey, expires);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string BuildPublicUrl(string objectKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CdnBaseUrl))
|
||||
{
|
||||
return $"{CdnBaseUrl!.TrimEnd('/')}/{objectKey}";
|
||||
}
|
||||
|
||||
var endpoint = new Uri(ServiceUrl);
|
||||
var scheme = UseHttps ? "https" : "http";
|
||||
return $"{scheme}://{Bucket}.{endpoint.Host}/{objectKey}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名 URL。
|
||||
/// </summary>
|
||||
/// <param name="objectKey">对象键。</param>
|
||||
/// <param name="expires">过期时间。</param>
|
||||
/// <param name="verb">HTTP 动作。</param>
|
||||
/// <param name="contentType">可选的内容类型约束。</param>
|
||||
protected virtual string GenerateSignedUrl(string objectKey, TimeSpan expires, HttpVerb verb = HttpVerb.GET, string? contentType = null)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = Bucket,
|
||||
Key = objectKey,
|
||||
Verb = verb,
|
||||
Expires = DateTime.UtcNow.Add(expires),
|
||||
Protocol = UseHttps ? Protocol.HTTPS : Protocol.HTTP
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.Headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return Client.GetPreSignedURL(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 S3 客户端。
|
||||
/// </summary>
|
||||
protected virtual IAmazonS3 CreateClient()
|
||||
{
|
||||
// 1. 尽量推断鉴权 Region(腾讯云 COS 的 S3 兼容域名形如:cos.ap-beijing.myqcloud.com)
|
||||
var authenticationRegion = ResolveAuthenticationRegion(ServiceUrl);
|
||||
|
||||
// 2. 构建客户端配置
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = ServiceUrl,
|
||||
ForcePathStyle = ForcePathStyle,
|
||||
UseHttp = !UseHttps,
|
||||
AuthenticationRegion = authenticationRegion
|
||||
};
|
||||
|
||||
// 3. 创建客户端并返回
|
||||
var credentials = new BasicAWSCredentials(AccessKey, SecretKey);
|
||||
return new AmazonS3Client(credentials, config);
|
||||
}
|
||||
|
||||
private static string? ResolveAuthenticationRegion(string serviceUrl)
|
||||
{
|
||||
// 1. 解析 URL
|
||||
if (!Uri.TryCreate(serviceUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 提取 Host
|
||||
var host = uri.Host;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 按 COS 域名规则解析 Region
|
||||
const string cosPrefix = "cos.";
|
||||
const string cosSuffix = ".myqcloud.com";
|
||||
if (host.StartsWith(cosPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
host.EndsWith(cosSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var regionPart = host.Substring(cosPrefix.Length, host.Length - cosPrefix.Length - cosSuffix.Length);
|
||||
return string.IsNullOrWhiteSpace(regionPart) ? null : regionPart;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private IAmazonS3 Client => _client ??= CreateClient();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_client?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 存储提供商实现。
|
||||
/// </summary>
|
||||
public sealed class TencentCosStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor)
|
||||
: S3StorageProviderBase
|
||||
{
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override StorageProviderKind Kind => StorageProviderKind.TencentCos;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string Bucket => CurrentOptions.TencentCos.Bucket;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.Endpoint)
|
||||
? $"{(CurrentOptions.TencentCos.UseHttps ? "https" : "http")}://cos.{CurrentOptions.TencentCos.Region}.myqcloud.com"
|
||||
: CurrentOptions.TencentCos.Endpoint!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AccessKey => CurrentOptions.TencentCos.SecretId;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string SecretKey => CurrentOptions.TencentCos.SecretKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool UseHttps => CurrentOptions.TencentCos.UseHttps;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ForcePathStyle => CurrentOptions.TencentCos.ForcePathStyle;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.CdnBaseUrl)
|
||||
? CurrentOptions.TencentCos.CdnBaseUrl
|
||||
: CurrentOptions.CdnBaseUrl;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override TimeSpan SignedUrlExpiry =>
|
||||
TimeSpan.FromMinutes(Math.Max(1, CurrentOptions.Security.DefaultUrlExpirationMinutes));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 存储提供商解析器,实现基于配置的提供商选择。
|
||||
/// </summary>
|
||||
public sealed class StorageProviderResolver(IOptionsMonitor<StorageOptions> optionsMonitor, IEnumerable<IObjectStorageProvider> providers)
|
||||
: IStorageProviderResolver
|
||||
{
|
||||
private readonly IDictionary<StorageProviderKind, IObjectStorageProvider> _providerMap =
|
||||
providers.ToDictionary(x => x.Kind, x => x);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IObjectStorageProvider Resolve(StorageProviderKind? provider = null)
|
||||
{
|
||||
var target = provider ?? optionsMonitor.CurrentValue.Provider;
|
||||
if (_providerMap.TryGetValue(target, out var instance))
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"未注册存储提供商:{target}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Module.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 存储提供商类型枚举,便于通过配置选择具体的对象存储实现。
|
||||
/// </summary>
|
||||
public enum StorageProviderKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 对象存储。
|
||||
/// </summary>
|
||||
TencentCos = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 七牛云 Kodo 存储。
|
||||
/// </summary>
|
||||
QiniuKodo = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储。
|
||||
/// </summary>
|
||||
AliyunOss = 3
|
||||
}
|
||||
Reference in New Issue
Block a user