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

156 lines
6.1 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 Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
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.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)
{
// 1. 参数校验
if (string.IsNullOrWhiteSpace(request.PhoneNumber))
{
throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空");
}
if (string.IsNullOrWhiteSpace(request.Scene))
{
throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空");
}
// 2. 解析模板与缓存键
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";
// 3. 检查冷却期
await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false);
// 4. 生成验证码并发送短信
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}");
}
// 5. 写入验证码与冷却缓存
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)
{
// 1. 基础校验
if (string.IsNullOrWhiteSpace(request.Code))
{
return false;
}
// 2. 读取验证码
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;
}
// 3. 比对成功后清除缓存
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, "请求过于频繁,请稍后再试");
}
}
}