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

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

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

一、实现自定义数据验证

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

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

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

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特性,这个特性是需要我们自己定义的特性,它要实现的是本小节前面所说的必填字段要求,先来看一下代码,然后再针对代码进行具体的讲解:

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基类,用于处理短信发送的消息。我们来看一下具体的实现:

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 类中实现这个方法,代码如下:

// 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方法即可,代码如下:

/// <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类型的请求体参数,这个请求模型包含了手机号码列表和验证码用途等信息,它的代码如下:

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方法即可,代码如下:

/// <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,支持用户通过用户名、手机号或邮箱三种方式进行注册。

posted @ 2025-09-07 12:45  wzzkaifa  阅读(10)  评论(0)    收藏  举报