156 lines
6.1 KiB
C#
156 lines
6.1 KiB
C#
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, "请求过于频繁,请稍后再试");
|
||
}
|
||
}
|
||
}
|