53.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--集成短信发送功能

发布于:2025-09-06 ⋅ 阅读:(15) ⋅ 点赞:(0)

短信服务作为现代网站和移动应用不可或缺的组成部分,在用户交互和信息传递中扮演着重要角色。通过集成短信功能,我们可以为用户提供更安全的账户验证机制,实现手机号验证码登录和注册,增强账户安全性。同时,短信服务还能够及时向用户推送重要的系统通知和提醒,比如账户异常登录提醒、重要事项提醒等,提升用户体验和服务质量。在本文中,我们将实现这一重要功能,让我们的应用具备专业和完善的短信服务能力。

Tip:文中用到的短信服务商是 Twilio,具体注册流程请参考Twilio 注册

一、功能规划

在功能开发中,我们需要构建一个完整的短信服务系统架构。首先,我们将设计并实现一个抽象的短信服务接口,该接口将定义发送短信的基本契约和行为规范。接着,我们会创建对应的Twilio实现类来完成具体的短信发送逻辑。为了更好地集成Twilio短信服务平台,我们还将开发一个专门的Twilio服务扩展类,这个类将封装所有与Twilio平台交互的细节,包括身份验证、消息格式化、发送请求等功能。

功能看似很简单,但是在实际开发中我们需要考虑很多细节问题。首先是我们需要控制短信发送的频率,为了防止恶意攻击和资源滥用,我们需要实现发送频率限制和验证码有效期管理。另外,考虑到未来可能需要支持多个短信服务商,我们的架构设计需要具备良好的扩展性和可维护性。同时,我们还需要考虑短信内容的模板管理、发送记录的存储和查询、费用统计等运营需求。这些都需要我们在开发中认真规划和实现。

二、功能实现

在这篇文章中,我们只实现基本发送短信这个基本功能,它包括:短信发送、控制短信发送频率、Twilio服务扩展类。剩余的模板管理、发送记录的存储和查询、费用统计等功能我们将在后台管理这一章节进行,敬请期待。

2.1 规划接口

在本小节中,我们将一起设计短信接口。作为一个核心功能模块,短信接口的设计需要既要满足基本的业务需求,又要具备足够的扩展性和通用性。首要的功能自然是发送短信,这是整个短信服务的基础和核心。考虑到我们开发的是一个通用的短信服务模块,它不仅要支持普通的文本消息发送,还需要包含验证码相关的功能特性。这包括验证码的生成和发送,以及后续的验证码校验功能。这样,在其他服务需要用到短信功能时就不必在自己服务内部来重复编写代码了。下面的代码就是根据上面分析后编写出的短信接口:

using SP.Common.Message.SmS.Model;

namespace SP.Common.Message.SmS.Services;

/// <summary>
/// 短信发送接口
/// </summary>
public interface ISmSService
{
    /// <summary>
    /// 发送短信验证码
    /// </summary>
    /// <param name="toPhoneNumber">接收短信的电话号码</param>
    /// <param name="purpose">短信用途</param>
    /// <returns>任务</returns>
    Task SendVerificationCodeAsync(string toPhoneNumber, SmSPurposeEnum purpose);

    ///<summary>
    /// 发送普通短信
    /// </summary>
    /// <param name="toPhoneNumber">接收短信的电话号码</param>
    /// <param name="message">短信内容</param>
    /// <param name="purpose">短信用途</param>
    /// <returns>任务</returns>
    Task SendMessageAsync(string toPhoneNumber, string message, SmSPurposeEnum purpose);

    /// <summary>
    /// 验证短信验证码
    /// </summary>
    /// <param name="toPhoneNumber">接收短信的电话号码</param>
    /// <param name="purpose">短信用途</param>
    /// <param name="code">验证码</param>
    /// <returns>是否验证成功</returns>
    Task<bool> VerifyCodeAsync(string toPhoneNumber, SmSPurposeEnum purpose, string code);
}

在上面的接口代码中,我们编写了前面分析的短信功能的接口。值得注意的是,每个方法都包含了一个类型为SmSPurposeEnum的参数purpose,这个参数在整个短信功能中扮演着重要的角色。它不仅用于标记和区分不同类型的短信用途,还能帮助我们实现更精细化的业务管理。通过这个枚举参数,我们可以轻松地进行短信发送的数据统计分析,比如统计不同用途短信的发送量、成功率等关键指标。SmSPurposeEnum是一个枚举类型,它定义了系统中所有可能的短信用途,包括用户注册、登录验证、密码修改、手机号变更、营销推广以及各类提醒服务等场景。这个枚举类型的设计充分考虑了实际业务需求,同时也为将来可能出现的新用途预留了扩展空间,代码如下:

namespace SP.Common.Message.SmS.Model;

/// <summary>
/// 短信用途枚举
/// </summary>
public enum SmSPurposeEnum
{
    /// <summary>
    /// 注册
    /// </summary>
    Register = 1,

    /// <summary>
    /// 登录
    /// </summary>
    Login = 2,

    /// <summary>
    /// 修改密码
    /// </summary>
    ChangePassword = 3,

    /// <summary>
    /// 更换手机号
    /// </summary>
    ChangePhoneNumber = 4,
    
    /// <summary>
    /// 营销推广
    /// </summary>
    Marketing = 5,
    
    /// <summary>
    /// 提醒
    /// </summary>
    Reminder = 6
}
2.2 接口实现

接下来,我们就要实现上一小节的短信接口了。在实现过程中,我们需要考虑多个关键要素:首先是验证码的生成和管理,这涉及到验证码的随机性、有效期控制以及存储方案;其次是短信发送频率的限制,我们需要实现合理的限流机制来防止恶意调用和资源滥用;最后是与Twilio平台的集成。下面是实现代码:

using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SP.Common.ExceptionHandling.Exceptions;
using SP.Common.Message.SmS.Model;
using SP.Common.Redis;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;

namespace SP.Common.Message.SmS.Services.Impl;

/// <summary>
/// 短信服务实现类(使用Twilio)
/// </summary>
public class TwilioSmSServiceImpl : ISmSService
{
    private readonly ILogger<TwilioSmSServiceImpl> _logger;
    private readonly IRedisService _redis;
    private readonly TwilioSmsOptions _options;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="logger"></param>
    /// <param name="redis"></param>
    /// <param name="options"></param>
    public TwilioSmSServiceImpl(ILogger<TwilioSmSServiceImpl> logger, IRedisService redis,  IOptions<TwilioSmsOptions> options)
    {
        _logger = logger;
        _redis = redis;
        _options = options.Value;
        if (string.IsNullOrWhiteSpace(_options.AccountSid) || string.IsNullOrWhiteSpace(_options.AuthToken)
                                                           || (string.IsNullOrWhiteSpace(_options.FromNumber) &&
                                                               string.IsNullOrWhiteSpace(_options.MessagingServiceSid)))
        {
            _logger.LogError(
                "Twilio 短信服务未配置完整,短信功能将不可用。请检查配置项:AccountSid, AuthToken, FromNumber 或 MessagingServiceSid");
            throw new BusinessException("Twilio短信服务未配置完整");
        }

        // 初始化Twilio客户端
        TwilioClient.Init(_options.AccountSid, _options.AuthToken);
    }

    /// <summary>
    /// 发送短信验证码
    /// </summary>
    /// <param name="toPhoneNumber">接收短信的电话号码</param>
    /// <param name="purpose">短信用途</param>
    /// <returns>任务</returns>
    public async Task SendVerificationCodeAsync(string toPhoneNumber, SmSPurposeEnum purpose)
    {
        if (string.IsNullOrEmpty(toPhoneNumber))
        {
            _logger.LogError("发送短信失败,电话号码不能为空");
            throw new BusinessException("电话号码不能为空");
        }

        // 限流
        string limitKey = string.Format(SPRedisKey.SmsLimit, toPhoneNumber);
        await IsRateLimitedAsync(limitKey, toPhoneNumber);

        // 校验并记录当天发送次数(每日上限)
        string limitDayKey = string.Format(SPRedisKey.SmSLimitDay, toPhoneNumber);
        await IsCheckDailyLimitAsync(limitDayKey, toPhoneNumber);

        // 生成验证码
        string code = BuildCode();
        // 存储验证码到redis
        string codeKey = string.Format(SPRedisKey.SmSCode, toPhoneNumber, purpose);
        int ttl = _options.CodeTTLSeconds > 0 ? _options.CodeTTLSeconds : 300;
        await _redis.SetStringAsync(codeKey, code, ttl);
        // 设置发送间隔,防止频繁发送
        int interval = _options.SendIntervalSeconds > 0 ? _options.SendIntervalSeconds : 60;
        await _redis.SetStringAsync(limitKey, "1", interval);

        // 组装短信
        string messageBody =
            $"【{_options.Signature}】您的验证码是 {code}.有效期为{ttl / 60}分钟。如非本人操作,请忽略本短信。";
        // 发送短信
        await SendSmsAsync(toPhoneNumber, messageBody);
        _logger.LogInformation("发送短信验证码成功,电话号码:{PhoneNumber}, 用途:{Purpose},验证码:{code}", toPhoneNumber, purpose, code);
    }

    ///<summary>
    /// 发送普通短信
    /// </summary>
    /// <param name="toPhoneNumber">接收短信的电话号码</param>
    /// <param name="message">短信内容</param>
    /// <param name="purpose">短信用途</param>
    /// <returns>任务</returns>
    public async Task SendMessageAsync(string toPhoneNumber, string message,SmSPurposeEnum purpose)
    {
        if (string.IsNullOrEmpty(toPhoneNumber))
        {
            _logger.LogError("发送短信失败,电话号码不能为空");
            throw new BusinessException("电话号码不能为空");
        }

        if (string.IsNullOrEmpty(message))
        {
            _logger.LogError("发送短信失败,短信内容不能为空");
            throw new BusinessException("短信内容不能为空");
        }

        // 限流
        string limitKey = string.Format(SPRedisKey.SmsLimit, toPhoneNumber);
        await IsRateLimitedAsync(limitKey, toPhoneNumber);

        // 校验并记录当天发送次数(每日上限)
        string limitDayKey = string.Format(SPRedisKey.SmSLimitDay, toPhoneNumber);
        await IsCheckDailyLimitAsync(limitDayKey, toPhoneNumber);
        // 组装短信
        string messageBody =
            $"【{_options.Signature}{message}";
        // 发送短信
        await SendSmsAsync(toPhoneNumber, messageBody);
        _logger.LogInformation("发送短信成功,电话号码:{PhoneNumber},内容:{messageBody}", toPhoneNumber, messageBody);
    }

    /// <summary>
    /// 验证短信验证码
    /// </summary>
    /// <param name="toPhoneNumber">接收手机号</param>
    /// <param name="purpose">短信用途</param>
    /// <param name="code">验证码</param>
    /// <returns></returns>
    public async Task<bool> VerifyCodeAsync(string toPhoneNumber, SmSPurposeEnum purpose, string code)
    {
        if (string.IsNullOrEmpty(toPhoneNumber) || string.IsNullOrEmpty(code))
        {
            throw new BusinessException("电话号码或验证码不能为空");
        }

        string codeKey = string.Format(SPRedisKey.SmSCode, toPhoneNumber, purpose);
        var storedCode = await _redis.GetStringAsync(codeKey);
        if (storedCode == code)
        {
            // 验证成功,删除验证码
            await _redis.RemoveAsync(codeKey);
            _logger.LogInformation("验证短信验证码成功,电话号码:{PhoneNumber}, 用途:{Purpose}", toPhoneNumber, purpose);
            return true;
        }
        else
        {
            _logger.LogWarning("验证短信验证码失败,电话号码:{PhoneNumber}, 用途:{Purpose}", toPhoneNumber, purpose);
            return false;
        }
    }

    /// <summary>
    /// 发送短信
    /// </summary>
    private async Task SendSmsAsync(string toPhoneNumber, string messageBody)
    {
        PhoneNumber to = new PhoneNumber(toPhoneNumber);
        CreateMessageOptions messageOptions = new CreateMessageOptions(to)
        {
            Body = messageBody
        };
        if (!string.IsNullOrWhiteSpace(_options.MessagingServiceSid))
        {
            messageOptions.MessagingServiceSid = _options.MessagingServiceSid;
        }
        else
        {
            messageOptions.From = new PhoneNumber(_options.FromNumber);
        }

        await MessageResource.CreateAsync(messageOptions);
    }

    /// <summary>
    /// 生成验证码
    /// </summary>
    /// <returns></returns>
    private string BuildCode()
    {
        using var rng = RandomNumberGenerator.Create();
        var bytes = new byte[6];
        rng.GetBytes(bytes);
        var code = BitConverter.ToUInt32(bytes, 0) % 900000 + 100000;
        return code.ToString();
    }

    /// <summary>
    /// 限流
    /// </summary>
    /// <param name="limitKey">限流Key</param>
    /// <param name="toPhoneNumber">手机号</param>
    /// <returns></returns>
    private async Task IsRateLimitedAsync(string limitKey, string toPhoneNumber)
    {
        if (await _redis.ExistsAsync(limitKey))
        {
            _logger.LogWarning("发送短信失败,发送过于频繁,电话号码:{PhoneNumber}", toPhoneNumber);
            throw new BusinessException("发送过于频繁,请稍后再试");
        }
    }

    /// <summary>
    /// 每天发送上限
    /// </summary>
    /// <param name="limitDayKey">每日限流Key</param>
    /// <param name="toPhoneNumber">手机号</param>
    /// <returns></returns>
    private async Task IsCheckDailyLimitAsync(string limitDayKey, string toPhoneNumber)
    {
        int sendNumLimitPerDay = _options.SendNumLimitPerDay > 0 ? _options.SendNumLimitPerDay : 5;
        int daySeconds = (int)(DateTime.Today.AddDays(1) - DateTime.Now).TotalSeconds;

        if (!await _redis.ExistsAsync(limitDayKey))
        {
            // 第一次发送:写入计数并设置当天过期时间
            await _redis.HashSetAsync(limitDayKey, "count", "1");
            await _redis.SetExpiryAsync(limitDayKey, daySeconds);
        }
        else
        {
            var countStr = await _redis.HashGetAsync(limitDayKey, "count");
            int currentCount = 0;
            _ = int.TryParse(countStr, out currentCount);
            if (currentCount >= sendNumLimitPerDay)
            {
                _logger.LogWarning("发送短信失败,超过每日发送上限,电话号码:{PhoneNumber}", toPhoneNumber);
                throw new BusinessException("今日发送次数已达上限");
            }

            await _redis.HashSetAsync(limitDayKey, "count", (currentCount + 1).ToString());
        }
    }
}

上面的代码实现了一个完整的短信实现类TwilioSmSServiceImpl。这个实现类通过Twilio平台提供短信发送功能,实现了包含验证码发送、普通短信发送功能,同时我们自己也实现了验证码校验功能。

在构造函数中,类首先注入了必要的依赖:日志服务、Redis服务和Twilio配置选项。构造函数会验证Twilio的关键配置是否完整,包括AccountSidAuthToken以及FromNumberMessagingServiceSid。如果配置不完整,将抛出业务异常。配置验证通过后,会初始化Twilio客户端以便后续使用。

SendVerificationCodeAsync方法实现了验证码短信的发送功能。这个方法首先验证输入的手机号是否有效,然后通过Redis实现了两层限流保护:单次发送间隔限制和每日发送次数限制。验证码生成采用了RandomNumberGenerator来确保安全性,生成6位数字验证码。验证码会被存储在Redis中并设置过期时间,同时发送限制信息也会写入Redis。最后,方法会组装包含签名的短信内容并调用Twilio API发送。

SendMessageAsync方法实现了普通短信的发送功能。这个方法的流程与验证码发送类似,但省去了验证码生成和存储的步骤。它同样实现了限流保护,并在发送前为消息内容添加签名。

VerifyCodeAsync方法用于验证用户提供的验证码。它会从Redis中获取存储的验证码进行比对,如果验证成功则删除存储的验证码并返回true,否则返回false。这确保了验证码只能使用一次。

代码还包含了几个私有辅助方法:SendSmsAsync封装了与Twilio API的直接交互;BuildCode负责生成安全的随机验证码;IsRateLimitedAsyncIsCheckDailyLimitAsync分别处理发送频率限制和每日发送次数限制的逻辑。这些方法共同构建了一个安全、可靠的短信服务实现。

Tip:目前我们只完成了短信的基本功能,后续我们将根据业务去扩展。

2.3 服务扩展类

短信功能完成了,要想使用它就需要将短信功能注入到项目里,为了方便使用,我们需要创建一个服务扩展类来简化注入过程。这个扩展类将封装所有配置细节,使微服务能够通过简单的方法调用来启用短信服务。这种方式能确保所有必要的服务和依赖都被正确注册。也为未来可能的功能扩展提供了良好的基础,使我们能够在不影响现有代码的情况下轻松添加新的功能。扩展服务比较简单,我们先来看一下代码:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SP.Common.Message.SmS.Model;
using SP.Common.Message.SmS.Services;
using SP.Common.Message.SmS.Services.Impl;

namespace SP.Common.Message.SmS;

/// <summary>
/// Twilio 短信服务扩展类
/// </summary>
public static class TwilioSmSExtensions
{
    /// <summary>
    /// 添加Twilio短信服务
    /// </summary>
    /// <param name="services">服务集合</param>
    /// <param name="configuration">配置</param>
    /// <returns>服务集合</returns>
    public static IServiceCollection AddTwilioSmSService(this IServiceCollection services, IConfiguration configuration)
    {
        services.Configure<TwilioSmsOptions>(configuration.GetSection("Twilio"));
        services.AddScoped<ISmSService, TwilioSmSServiceImpl>();
        return services;
    }
}

上面的代码实现了一个静态扩展类TwilioSmSExtensions,它的核心是AddTwilioSmSService方法,它接收两个参数:服务集合(IServiceCollection)和配置对象(IConfiguration)。这个扩展方法首先通过Configure<TwilioSmsOptions>将配置文件中的"Twilio"节点绑定到TwilioSmsOptions选项类。这种方式允许我们在应用程序启动时从配置中读取Twilio相关的配置信息,包括AccountSidAuthToken等重要参数。接着,方法使用AddScoped生命周期注册了短信服务的实现。它将接口ISmSService与具体实现类TwilioSmSServiceImpl进行映射,采用Scoped生命周期,在同一个HTTP请求范围内,服务会保持同一个实例。

三、总结

在本文中,我们构建了一个灵活且可扩展的短信服务系统。文章重点展示了如何使用Twilio作为短信服务提供商,实现了包括验证码发送、普通短信发送以及验证码校验等核心功能。在实现过程中,我们特别注重了安全性和可靠性,通过Redis实现了发送频率限制和验证码管理,同时提供了便捷的服务扩展类以简化集成过程。这些功能的实现为应用提供了专业的短信服务能力,为后续功能的扩展和完善奠定了坚实的基础。


网站公告

今日签到

点亮在社区的每一天
去签到