在现代互联网应用中,为了提供更便捷的用户体验,大多数网站和移动应用都实现了多渠道的登录注册功能。用户可以选择使用手机号或电子邮箱作为账号,系统会通过短信或邮件的方式发送验证码。这种登录方式不仅简化了用户的注册流程,避免了传统账号密码的记忆负担,还能有效提升账号的安全性。当用户输入手机号或邮箱后,只需点击获取验证码按钮,系统就会立即发送一个临时的验证码。用户在收到验证码后,输入到登录界面的验证框中,系统验证正确后就能直接完成登录。
在本篇文章中,我们将一起实现用户注册功能。注册流程设计采用了验证码验证的方式,当用户输入手机号或邮箱后,系统会生成一个随机的验证码并发送到用户提供的手机或邮箱中。用户收到验证码后,需要在注册界面的验证码输入框中填写正确的验证码。系统会对用户输入的验证码进行校验,包括验证码是否正确以及是否在有效期内。只有当验证码验证通过后,系统才会执行后续的注册逻辑,包括创建用户账号、初始化用户信息等操作。
一、实现自定义数据验证
我们需要实现数据验证功能来校验前端提交的注册信息。系统支持三种注册方式:用户名注册、手机号注册和邮箱注册。每种注册方式都有其特定的必填字段要求:
- 用户名注册:需要填写用户名和密码
- 手机号注册:需要填写手机号和验证码
- 邮箱注册:需要填写邮箱地址和验证码
为此,我们将实现自定义数据验证逻辑,确保用户根据所选注册方式提供了所有必要信息。接下来我们需要修改 用户注册请求模型 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
类。这个特性主要用于实现对象级别的复杂验证规则,特别是处理多个属性之间的关联验证逻辑。
该特性包含两个重要的属性:AnyOf
和RequireIfPresent
。AnyOf
用于指定一组属性中至少需要填写一个的场景,比如用户注册时可以使用用户名、邮箱或手机号中的任意一个。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.SmSVerificationCode
和MessageType.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
完成用户创建。类似地,RegisterByEmailAsync
和RegisterByPhoneNumberAsync
方法分别实现了基于邮箱和手机号的注册逻辑,它们都会验证验证码的正确性,并在注册成功后清除已使用的验证码。
四、总结
本文详细介绍了如何在项目中实现多渠道用户注册功能。我们首先通过自定义验证特性ObjectRulesAttribute
实现了灵活的数据验证机制,可以根据不同的注册方式(用户名、手机号、邮箱)动态验证必填字段。接着,我们基于RabbitMQ消息队列实现了短信验证码的异步发送功能,通过SmSConsumerService
消费者服务来处理实际的短信发送任务。最后,我们完善了用户注册API,支持用户通过用户名、手机号或邮箱三种方式进行注册。