feat: finalize core modules and gateway

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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