54.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--实现手机邮箱注册

在现代互联网应用中,为了提供更便捷的用户体验,大多数网站和移动应用都实现了多渠道的登录注册功能。用户可以选择使用手机号或电子邮箱作为账号,系统会通过短信或邮件的方式发送验证码。这种登录方式不仅简化了用户的注册流程,避免了传统账号密码的记忆负担,还能有效提升账号的安全性。当用户输入手机号或邮箱后,只需点击获取验证码按钮,系统就会立即发送一个临时的验证码。用户在收到验证码后,输入到登录界面的验证框中,系统验证正确后就能直接完成登录。

在本篇文章中,我们将一起实现用户注册功能。注册流程设计采用了验证码验证的方式,当用户输入手机号或邮箱后,系统会生成一个随机的验证码并发送到用户提供的手机或邮箱中。用户收到验证码后,需要在注册界面的验证码输入框中填写正确的验证码。系统会对用户输入的验证码进行校验,包括验证码是否正确以及是否在有效期内。只有当验证码验证通过后,系统才会执行后续的注册逻辑,包括创建用户账号、初始化用户信息等操作。

一、实现自定义数据验证

我们需要实现数据验证功能来校验前端提交的注册信息。系统支持三种注册方式:用户名注册、手机号注册和邮箱注册。每种注册方式都有其特定的必填字段要求:

  • 用户名注册:需要填写用户名和密码
  • 手机号注册:需要填写手机号和验证码
  • 邮箱注册:需要填写邮箱地址和验证码

为此,我们将实现自定义数据验证逻辑,确保用户根据所选注册方式提供了所有必要信息。接下来我们需要修改 用户注册请求模型 UserRegisterRequest类,在里面增加手机号字段PhoneNumber、验证码字段Code和注册类型字段RegisterType,并删除里面的部分校验特性,修改后的代码如下:

csharp 复制代码
using System.ComponentModel.DataAnnotations;
using SP.Common.Attributes;
using SP.IdentityService.Models.Enumeration;

namespace SP.IdentityService.Models.Request;

/// <summary>
/// 用户注册请求模型
/// </summary>
[ObjectRules(AnyOf = new[] { "UserName", "Email", "PhoneNumber" },
    RequireIfPresent = new[] { "UserName=>Password", "Email=>Code", "PhoneNumber=>Code" })]
public class UserRegisterRequest
{
    /// <summary>
    /// 用户名
    /// </summary>
    [StringLength(50, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-50个字符之间")]
    [RegularExpression(@"^[a-zA-Z0-9_-]+$", ErrorMessage = "用户名只能包含字母、数字、下划线和连字符")]
    public string UserName { get; set; }

    /// <summary>
    /// 密码
    /// </summary>
    [StringLength(100, MinimumLength = 6, ErrorMessage = "密码长度必须在6-100个字符之间")]
    public string Password { get; set; }

    /// <summary>
    /// 邮箱
    /// </summary>
    [EmailAddress(ErrorMessage = "邮箱格式不正确")]
    [StringLength(100, ErrorMessage = "邮箱长度不能超过100个字符")]
    public string? Email { get; set; }

    /// <summary>
    /// 手机号
    /// </summary>
    [Phone(ErrorMessage = "手机号格式不正确")]
    [StringLength(20, ErrorMessage = "手机号长度不能超过20个字符")]
    public string? PhoneNumber { get; set; }
    
    /// <summary>
    /// 验证码
    /// </summary>
    public string Code { get; set; }
    
    /// <summary>
    /// 注册类型
    /// </summary>
    [Required(ErrorMessage = "注册类型不能为空")]
    public RegisterTypeEnum RegisterType { get; set; }
}

我们看到在UserRegisterRequest类的头部有ObjectRules特性,这个特性是需要我们自己定义的特性,它要实现的是本小节前面所说的必填字段要求,先来看一下代码,然后再针对代码进行具体的讲解:

csharp 复制代码
using System.ComponentModel.DataAnnotations;

namespace SP.Common.Attributes;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class ObjectRulesAttribute : ValidationAttribute
{
    public string[] AnyOf { get; set; } = Array.Empty<string>();
    // 规则格式:"A=>B" 表示当 A 有值时,B 必填
    public string[] RequireIfPresent { get; set; } = Array.Empty<string>();

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (value == null) return ValidationResult.Success;

        var type = value.GetType();
        var errors = new List<ValidationResult>();

        bool HasValue(string? s) => !string.IsNullOrWhiteSpace(s);

        // AnyOf:至少一个字段有值
        if (AnyOf != null && AnyOf.Length > 0)
        {
            var anyHas = AnyOf.Any(p =>
            {
                var v = type.GetProperty(p)?.GetValue(value) as string;
                return HasValue(v);
            });
            if (!anyHas)
            {
                errors.Add(new ValidationResult(
                    $"以下字段至少填写一个:{string.Join(", ", AnyOf)}",
                    AnyOf));
            }
        }

        // RequireIfPresent:当 A 有值时,B 必填
        if (RequireIfPresent != null && RequireIfPresent.Length > 0)
        {
            foreach (var rule in RequireIfPresent)
            {
                var parts = rule.Split("=>", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
                if (parts.Length != 2) continue;

                var src = parts[0];
                var dst = parts[1];

                var srcVal = type.GetProperty(src)?.GetValue(value) as string;
                var dstVal = type.GetProperty(dst)?.GetValue(value) as string;

                if (HasValue(srcVal) && !HasValue(dstVal))
                {
                    errors.Add(new ValidationResult(
                        $"当填写了"{src}"时,"{dst}"为必填项",
                        new[] { dst }));
                }
            }
        }

        if (errors.Count > 0)
        {
            if (errors.Count == 1) return errors[0];
            // 聚合错误
            var members = errors.SelectMany(e => e.MemberNames).Distinct().ToArray();
            return new ValidationResult(string.Join(";", errors.Select(e => e.ErrorMessage)), members);
        }

        return ValidationResult.Success;
    }
}

在上面的代码中,我们定义了一个名为ObjectRulesAttribute的自定义验证特性,它继承自ValidationAttribute类。这个特性主要用于实现对象级别的复杂验证规则,特别是处理多个属性之间的关联验证逻辑。

该特性包含两个重要的属性:AnyOfRequireIfPresentAnyOf用于指定一组属性中至少需要填写一个的场景,比如用户注册时可以使用用户名、邮箱或手机号中的任意一个。RequireIfPresent则用于定义条件性的必填规则,采用"A=>B"的格式,表示当A属性有值时,B属性必须填写,这适用于例如选择邮箱注册时必须填写验证码的场景。

在特性的核心验证逻辑中,IsValid方法首先会检查AnyOf规则,确保指定的属性集合中至少有一个属性被填写。然后检查RequireIfPresent规则,验证所有条件性的必填要求是否得到满足。如果发现任何验证错误,方法会收集这些错误并返回适当的验证结果。当存在多个验证错误时,这些错误会被合并成一个统一的验证结果,包含所有相关的错误信息和受影响的属性名称。

通过这个特性,我们可以在模型类上通过简单的特性声明来实现复杂的验证逻辑,而不需要在控制器或服务层编写大量的验证代码。

二、实现发送手机验证码API

完成了自定义数据验证的代码后,我们需要实现发送手机验证码API的功能。这个功能将基于我们在上一篇文章中封装的短信发送接口来构建,同时会结合我们之前封装的消息队列(MQ)来实现异步处理。通过使用消息队列,我们可以将验证码发送请求解耦,提高系统的响应速度和可靠性。当用户请求发送验证码时,系统会生成一个随机验证码,将发送任务提交到消息队列中,然后由专门的消费者服务来处理实际的短信发送操作。

2.1 实现短信消息队列消费者

首先,我们需要实现发送短信的消息队列的消费者,这个消费者是通用的短信消费者。在SP.Common项目中的 SP.Common/Message/Mq 文件夹下创建 Consumer 文件夹,并在其中创建SmSConsumerService类实现短信消息的消费者类。这个消费者类将实现BackgroundService基类,用于处理短信发送的消息。我们来看一下具体的实现:

csharp 复制代码
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SP.Common.Message.Model;
using SP.Common.Message.Mq.Model;
using SP.Common.Message.SmS.Services;

namespace SP.Common.Message.Mq.Consumer;

/// <summary>
/// 短信消费者服务
/// </summary>
public class SmSConsumerService : BackgroundService
{
    /// <summary>
    /// 日志
    /// </summary>
    private readonly ILogger<SmSConsumerService> _logger;

    /// <summary>
    /// RabbitMq 消息
    /// </summary>
    private readonly RabbitMqMessage _rabbitMqMessage;

    /// <summary>
    /// 短信服务
    /// </summary>
    private readonly ISmSService _smSService;

    /// <summary>
    /// 构造函数
    /// </summary>
    public SmSConsumerService(ILogger<SmSConsumerService> logger,
        RabbitMqMessage rabbitMqMessage,
        ISmSService smSService)
    {
        _logger = logger;
        _rabbitMqMessage = rabbitMqMessage;
        _smSService = smSService;
    }

    /// <summary>
    /// 执行异步任务
    /// </summary>
    /// <param name="stoppingToken"></param>
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        MqSubscriber subscriber = new MqSubscriber(MqExchange.MessageExchange,
            MqRoutingKey.SmSRoutingKey, MqQueue.MessageQueue);
        await _rabbitMqMessage.ReceiveAsync(subscriber, async message =>
        {
            MqMessage mqMessage = message as MqMessage;
            string body = mqMessage.Body;
            SmSMessage? smSMessage = JsonSerializer.Deserialize<SmSMessage>(body);
            if (smSMessage == null)
            {
                _logger.LogError("消息体解析失败");
                return;
            }

            // 发送验证码
            if (mqMessage.Type == MessageType.SmSVerificationCode)
            {
                await _smSService.SendVerificationCodeAsync(smSMessage.PhoneNumber, smSMessage.Purpose);
            }
            else if (message.Type == MessageType.SmSGeneral)
            {
                await _smSService.SendMessageAsync(smSMessage.PhoneNumber, smSMessage.Message, smSMessage.Purpose);
            }
            else
            {
                _logger.LogError("消息类型错误");
            }

            await Task.CompletedTask;
        });
    }
}

在上面的代码中,我们实现了一个短信消费者服务类SmSConsumerService。这个服务继承自BackgroundService,用于在后台持续运行并处理短信发送任务。该服务通过依赖注入获取所需的日志记录器、RabbitMQ消息服务和短信服务实例。

在服务的核心执行逻辑中,我们重写了ExecuteAsync方法。该方法首先创建了一个消息订阅者,指定了消息交换机、路由键和队列名称。然后通过_rabbitMqMessage.ReceiveAsync方法开始监听消息队列。当收到消息时,会将消息体反序列化为SmSMessage对象,这个对象包含了发送短信所需的所有信息,如手机号码、消息内容和用途等。

消息处理逻辑根据消息类型进行分流处理。如果是验证码类型的消息(MessageType.SmSVerificationCode),系统会调用短信服务的SendVerificationCodeAsync方法发送验证码;如果是普通短信类型(MessageType.SmSGeneral),则调用SendMessageAsync方法发送普通短信。

在代码中用到的MqRoutingKey.SmSRoutingKey是为短信发送消息队列增加的路由键,MessageType.SmSVerificationCodeMessageType.SmSGeneral是为区分短信发送类型而新增的。由于代码很简单,因此这里就不在展示了,大家可以访问专栏对应的GitHub代码库中查看。

Tip:由于在以前的文章中我们已经实现了邮箱发送验证码的功能,因此我们这里就不再赘述了。

2.2 实现发送手机验证码API

我们现在开始实现发送手机验证码API,我们需要在IAuthorizationService 接口中新增SendVerificationCodeAsync方法,并在AuthorizationServiceImpl 类中实现这个方法,代码如下:

csharp 复制代码
// IAuthorizationService 接口增加
/// <summary>
/// 发送短信验证码
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="purpose">用途</param>
/// <returns></returns>
Task SendVerificationCodeAsync(string phoneNumber, SmSPurposeEnum purpose);

//-----------------------------------------------------------------------------------

// AuthorizationServiceImpl 实现
/// <summary>
/// 发送短信验证码
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="purpose">用途</param>
/// <returns></returns>
public async Task SendVerificationCodeAsync(string phoneNumber, SmSPurposeEnum purpose)
{
    SmSMessage smsMessage = new SmSMessage();
    smsMessage.PhoneNumber = phoneNumber;
    smsMessage.Purpose = purpose;
    string body = JsonSerializer.Serialize(smsMessage);
    // 发送短信验证码MQ
    MqPublisher publisher = new MqPublisher(body,
        MqExchange.MessageExchange,
        MqRoutingKey.SmSRoutingKey,
        MqQueue.MessageQueue,
        MessageType.SmSVerificationCode,
        ExchangeType.Direct);
    await _rabbitMqMessage.SendAsync(publisher);
}

这段代码实现了发送短信验证码的功能。首先创建一个SmSMessage对象用于封装短信发送所需的信息,包括手机号和验证码用途。然后将这个对象序列化为JSON字符串,作为消息体。接着构造一个MqPublisher发布者对象,指定消息交换机为MessageExchange、路由键为SmSRoutingKey、队列为MessageQueue,消息类型为SmSVerificationCode,交换机类型为Direct。最后通过_rabbitMqMessage服务的SendAsync方法将消息发送到消息队列中。

最后,我们在控制器AuthorizationController中新增SmsVerificationCode Action,在这个Action中我们直接调用SendVerificationCodeAsync方法即可,代码如下:

csharp 复制代码
/// <summary>
/// 发送手机验证码
/// </summary>
/// <param name="smSRequest"></param>
[HttpPost("smsVerificationCode")]
public async Task<ActionResult> SmsVerificationCode([FromBody] SmSRequest smSRequest)
{
    await _authorizationService.SendVerificationCodeAsync(smSRequest.PhoneNumbers[0], smSRequest.Purpose);
    return Ok();
}

在上面的代码中,我们实现了一个发送手机验证码的API接口。这个接口通过HTTP POST方法暴露,路由为·、smsVerificationCode。该接口接收一个SmSRequest类型的请求体参数,这个请求模型包含了手机号码列表和验证码用途等信息,它的代码如下:

csharp 复制代码
using System.ComponentModel.DataAnnotations;

namespace SP.Common.Message.SmS.Model;

/// <summary>
/// 短信发送通用类
/// </summary>
public class SmSRequest
{
    /// <summary>
    /// 接收短信的电话号码
    /// </summary>
    [Required(ErrorMessage = "电话号码不能为空")]
    public List<string> PhoneNumbers { get; set; }

    /// <summary>
    /// 短信用途
    /// </summary>
    [Required(ErrorMessage = "短信用途不能为空")]
    public SmSPurposeEnum Purpose { get; set; }
    
    /// <summary>
    /// 短信内容(用于发送普通短信)
    /// </summary>
    public string Message { get; set; }
}

当接口被调用时,它会从请求中获取第一个手机号码(通过smSRequest.PhoneNumbers[0]访问)和验证码用途(smSRequest.Purpose),然后调用授权服务(_authorizationService)的SendVerificationCodeAsync方法来处理验证码发送逻辑。这个方法会将发送验证码的任务提交到消息队列中进行异步处理,避免同步等待发送过程而阻塞请求线程。

三、实现手机/邮箱注册API

在本小节中,我们将着手实现手机号和邮箱注册的API功能。这个功能将基于我们现有的注册API进行扩展和改造,以支持多种注册方式。我们需要修改现有的注册逻辑,使其能够根据用户选择的注册类型(用户名、手机号或邮箱)来执行相应的注册流程。在实现过程中,我们将确保系统能够正确处理不同类型的注册请求,并在注册成功后返回统一的用户信息格式。我们只需要修改AuthorizationServiceImpl类中的AddUserAsync方法即可,代码如下:

csharp 复制代码
/// <summary>
/// 添加用户
/// </summary>
/// <param name="user"></param>
/// <returns>用户id</returns>
public async Task<long> AddUserAsync(UserRegisterRequest user)
{
    long userId = 0;
    switch (user.RegisterType)
    {
        case RegisterTypeEnum.UserName:
            userId = await RegisterByUserNameAsync(user.UserName, user.Password);
            break;
        case RegisterTypeEnum.Email:
            userId = await RegisterByEmailAsync(user.Email, user.Code);
            break;
        case RegisterTypeEnum.PhoneNumber:
            userId = await RegisterByPhoneNumberAsync(user.PhoneNumber, user.Code);
            break;
    }

    // 发送mq,设配默认币种
    MqPublisher publisher = new MqPublisher(userId.ToString(),
        MqExchange.UserConfigExchange,
        MqRoutingKey.UserConfigDefaultCurrencyRoutingKey,
        MqQueue.UserConfigQueue,
        MessageType.UserConfigDefaultCurrency,
        ExchangeType.Direct);
    await _rabbitMqMessage.SendAsync(publisher);
    return userId;
}

/// <summary>
/// 创建用户并分配默认角色,带事务
/// </summary>
/// <param name="newUser">即将创建的用户</param>
/// <param name="password">可选密码(为空则不设置密码)</param>
/// <param name="afterCommit">事务提交后的可选回调</param>
/// <returns>用户ID</returns>
private async Task<long> CreateUserWithDefaultRoleAsync(SpUser newUser, string? password = null,
    Func<Task>? afterCommit = null)
{
    using var transaction = _dbContext.Database.BeginTransaction();
    try
    {
        IdentityResult result = password == null
            ? await _userManager.CreateAsync(newUser)
            : await _userManager.CreateAsync(newUser, password);
        if (result.Succeeded)
        {
            var roleResult = await _userManager.AddToRoleAsync(newUser, "User");
            if (!roleResult.Succeeded)
            {
                await _userManager.DeleteAsync(newUser);
                throw new Exception("用户创建成功,但分配角色失败:" +
                                    string.Join(",", roleResult.Errors.Select(e => e.Description)));
            }

            await transaction.CommitAsync();

            if (afterCommit != null)
            {
                await afterCommit();
            }

            return newUser.Id;
        }

        throw new Exception(string.Join(",", result.Errors.Select(e => e.Description)));
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

/// <summary>
/// 使用用户名注册
/// </summary>
/// <param name="userName">用户名</param>
/// <param name="password">密码</param>
/// <returns>用户ID</returns>
private async Task<long> RegisterByUserNameAsync(string userName, string password)
{
    // 检查userName是否存在
    var existingUser = await _userManager.FindByNameAsync(userName);
    if (existingUser != null)
    {
        throw new BusinessException("用户名已存在");
    }

    // 创建用户
    var newUser = new SpUser
    {
        Id = Snow.GetId(),
        UserName = userName,
    };
    return await CreateUserWithDefaultRoleAsync(newUser, password);
}

/// <summary>
/// 使用邮箱注册
/// </summary>
/// <param name="email">邮箱</param>
/// <param name="code">验证码</param>
/// <returns>用户ID</returns>
private async Task<long> RegisterByEmailAsync(string email, string code)
{
    // 验证邮箱
    var emailUser = await _userManager.FindByEmailAsync(email);
    if (emailUser != null)
    {
        throw new BusinessException("邮箱已存在");
    }

    // 验证验证码
    var redisCode = await _redis.GetStringAsync(email);
    if (string.IsNullOrEmpty(redisCode))
    {
        throw new BusinessException("验证码已过期或不存在");
    }

    if (redisCode != code.Trim())
    {
        throw new BusinessException("验证码错误");
    }

    // 创建用户
    var newUser = new SpUser
    {
        Id = Snow.GetId(),
        UserName = email,
        Email = email,
        EmailConfirmed = true
    };
    return await CreateUserWithDefaultRoleAsync(newUser, null, async () =>
    {
        // 删除Redis中的验证码
        await _redis.RemoveAsync(email);
    });
}

/// <summary>
/// 使用手机号注册
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="code">验证码</param>
/// <returns>用户ID</returns>
private async Task<long> RegisterByPhoneNumberAsync(string phoneNumber, string code)
{
    // 验证手机号
    var phoneUser = await _userManager.Users.FirstOrDefaultAsync(u => u.PhoneNumber == phoneNumber);
    if (phoneUser != null)
    {
        throw new BusinessException("手机号已存在");
    }

    // 验证验证码
    bool isOk = await _smsService.VerifyCodeAsync(phoneNumber, SmSPurposeEnum.Register, code);
    if (!isOk)
    {
        throw new BusinessException("验证码错误");
    }

    // 创建用户
    var newUser = new SpUser
    {
        Id = Snow.GetId(),
        UserName = phoneNumber,
        PhoneNumber = phoneNumber,
        PhoneNumberConfirmed = true
    };
    return await CreateUserWithDefaultRoleAsync(newUser);
}

让我们详细分析上面实现的用户注册相关代码。首先看AddUserAsync方法,这是处理用户注册的主要入口。该方法接收一个UserRegisterRequest参数,根据注册类型RegisterType使用switch语句将请求分发到不同的注册处理方法。对于用户名注册,调用RegisterByUserNameAsync;邮箱注册调用RegisterByEmailAsync;手机号注册则调用RegisterByPhoneNumberAsync。注册成功后,方法会通过消息队列发送一个设置用户默认币种的消息,这体现了系统的解耦设计。

核心的用户创建逻辑封装在CreateUserWithDefaultRoleAsync私有方法中。这个方法使用事务来确保用户创建和角色分配的原子性。它首先尝试创建用户,可以选择是否设置密码。创建成功后,会为用户分配默认的 User 角色。如果角色分配失败,会回滚整个事务并删除已创建的用户。方法还支持通过afterCommit参数传入一个在事务提交后执行的回调函数,这为扩展注册后的处理流程提供了灵活性。

RegisterByUserNameAsync方法为例,它实现了基于用户名的注册流程。首先检查用户名是否已存在,然后创建新的SpUser对象,设置必要的用户信息,最后调用CreateUserWithDefaultRoleAsync完成用户创建。类似地,RegisterByEmailAsyncRegisterByPhoneNumberAsync方法分别实现了基于邮箱和手机号的注册逻辑,它们都会验证验证码的正确性,并在注册成功后清除已使用的验证码。

四、总结

本文详细介绍了如何在项目中实现多渠道用户注册功能。我们首先通过自定义验证特性ObjectRulesAttribute实现了灵活的数据验证机制,可以根据不同的注册方式(用户名、手机号、邮箱)动态验证必填字段。接着,我们基于RabbitMQ消息队列实现了短信验证码的异步发送功能,通过SmSConsumerService消费者服务来处理实际的短信发送任务。最后,我们完善了用户注册API,支持用户通过用户名、手机号或邮箱三种方式进行注册。

相关推荐
点灯小铭1 天前
基于STM32单片机的智能粮仓温湿度检测蓝牙手机APP设计
stm32·单片机·智能手机·毕业设计·课程设计
眠りたいです1 天前
基于脚手架微服务的视频点播系统-播放控制部分
c++·qt·ui·微服务·云原生·架构·播放器
叫我阿柒啊1 天前
Java全栈开发工程师的实战面试经历:从基础到微服务
java·微服务·typescript·vue·springboot·前端开发·后端开发
双翌视觉1 天前
机器视觉的手机柔性屏贴合应用
智能手机·自动化·视觉检测·机器视觉
chinesegf1 天前
手机能看、投屏 / 车机不能看与反向链接验证类似吗?
智能手机
wanhengidc1 天前
云手机运行流畅,秒开不卡顿
运维·网络·科技·游戏·智能手机
Jerry&Grj1 天前
SpringBoot埋点功能技术实现方案深度解析:架构设计、性能优化与扩展性实践
java·微服务·性能优化·springboot·架构设计·埋点技术
程序猿阿伟1 天前
《云原生微服务治理进阶:隐性风险根除与全链路能力构建》
微服务·云原生·架构