Files
TakeoutSaaS.AdminApi/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs

149 lines
5.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
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() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
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, "请求过于频繁,请稍后再试");
}
}
}