第四节:DDD用户登录案例(需求分析与设计、项目快速搭建实操)
一. 需求分析和设计
1. 需求说明
主要包括用户管理、用户登录、发送验证码等功能。
(1). 用户管理包括:添加用户、获取所有用户信息、修改密码、解除登录锁定 等功能。
(2). 用户登录包括: 发送验证码、校验验证码的准确性、通过手机号和密码登录。
A. 对于DB中存在的用户,如果超过3次登录失败,则会被锁定5min,期间不能登录,也不能发送验证码。
B. 登录成功后(非锁定期间),会重置登录失败的信息,清零 或 置空。
C. 对于DB中存在的用户,登录的时候,无论是登录成功 还是 各种原因导致的登录失败,都会在DB中存储登录记录,方便审计。
D. 对于DB中不存在的用户,是无法登录的,也不存储登录记录哦。
2. 项目分层
(1).领域层(User.Domain):实体类、事件、防腐层接口(ISmsCodeSender)、仓储接口、领域服务
PS:领域服务中存在一些业务判断, 但DB层次的操作还是在基础设施层处理
(2).基础设施层(User.Infrastructure):实体类配置、EFCore的DbContext、防腐层接口实现、仓储接口实现
(3).应用层(User.WebApi):Controller、事件(领域事件、集成事件)的响应类
A. 应用层主要进行的是数据的校验、请求数据的获取、领域服务返回值的显示等处理,并没有复杂的业务逻辑,因为主要的业务逻辑都被封装在领域层。
B. 应用层是非常薄的一层,应用层主要进行安全认证、权限校验、数据校验、事务控制、工作单元控制、领域服务的调用等。从理论上来讲,应用层中不应该有业务规则或者业务逻辑。
3. 项目间的关系
领域层(User.Domain)位于最内层
基础设施层(User.Infrastructure)位于第二层,调用领域层(User.Domain)
应用层(User.WebApi)位于最外层,调用 领域层(User.Domain) 和 基础设施层(User.Infrastructure)
二. 领域层搭建
1. 实体
都采用了充血模型,包含:属性、成员变量、方法
UserInfo(用户实体):包括修改密码、修改手机号、校验密码准确性等方法.
UserLoginFail(用户登录失败实体):包括重置、判断是否锁定、处理一次登录失败.
UserLoginHistory(用户登录记录实体):UserId属性是一个指向User实体的外键,但是在物理上,我们并没有创建它们的外键关系
PS:实体里的方法都是修改实体的属性,并没有进行相关的数据库操作。
UserInfo
/// <summary> /// 用户实体类 /// </summary> public record UserInfo { public Guid Id { get; init; } //主键(init表示只读,仅允许构造函数对其进行修改) public PhoneNumber PhoneNumber { get; private set; } //手机号(private set 表示只允许该类中的方法或构造函数对其进行修改) public UserLoginFail LoginFail { get; private set; } //登录失败实体 private string passWordHash; //密码的散列值Md5 (不属于属性的成员变量) /// <summary> /// 空构造函数,给EF用来返回实体的 /// </summary> public UserInfo() { } /// <summary> /// 构造函数1-创建实体 /// </summary> /// <param name="phoneNumber"></param> public UserInfo(PhoneNumber phoneNumber) { Id= Guid.NewGuid(); PhoneNumber= phoneNumber; this.LoginFail = new UserLoginFail(this); } /// <summary> /// 构造函数2-创建实体 /// </summary> /// <param name="phoneNumber"></param> /// <param name="pwd"></param> public UserInfo(PhoneNumber phoneNumber,string pwd) { Id = Guid.NewGuid(); PhoneNumber = phoneNumber; passWordHash = HashHelper.ComputeMd5Hash(pwd); ; this.LoginFail = new UserLoginFail(this); } /// <summary> /// 是否设置了密码 /// </summary> /// <returns></returns> public bool HasPassword() { return !string.IsNullOrEmpty(passWordHash); } /// <summary> /// 修改密码 /// </summary> /// <param name="newPwd">新密码</param> /// <exception cref="ArgumentException"></exception> public void ChangePassword(string newPwd) { if (newPwd.Length <= 3) { throw new ArgumentException("密码长度不能小于3"); } passWordHash = HashHelper.ComputeMd5Hash(newPwd); } /// <summary> /// 校验密码是否正确 /// </summary> /// <param name="password">新密码</param> /// <returns></returns> public bool CheckPassword(string password) { return passWordHash == HashHelper.ComputeMd5Hash(password); } /// <summary> /// 修改手机号 /// </summary> /// <param name="phoneNumber">新手机号</param> public void ChangePhoneNumber(PhoneNumber phoneNumber) { PhoneNumber = phoneNumber; } }
UserLoginFail
/// <summary> /// 用户登录失败实体类 /// </summary> public record UserLoginFail { public Guid Id { get; init; } //主键Id public Guid UserId { get; init; } //用户Id(外键) public UserInfo User { get; init; } //用户实体 public DateTime? LockoutEnd { get; private set; } //登录锁定结束时间 public int AccessFailedCount { get; private set; } //登录错误次数 private bool lockOut;//是否锁定 /// <summary> /// 空构造函数,给EF用来返回实体的 /// </summary> public UserLoginFail() { } /// <summary> /// 构造函数 /// </summary> /// <param name="user"></param> public UserLoginFail(UserInfo user) { Id = Guid.NewGuid(); User = user; } /// <summary> /// 重置登录错误信息 /// </summary> public void Reset() { lockOut = false; LockoutEnd = null; AccessFailedCount = 0; } /// <summary> /// 判断是否已经锁定 /// </summary> /// <returns></returns> public bool IsLockOut() { if (lockOut) { if (LockoutEnd >= DateTime.Now) { return true; //表示还是锁定的 } else { //表示锁定已经过期了 lockOut = false; LockoutEnd = null; //(注:这里错误次数没有重置) return false; } } else { return false; } } /// <summary> /// 处理一次登录失败 /// </summary> public void LoginFail() { AccessFailedCount++; //失败次数>=3, 开启锁定,过期时间为:当前时间+ 5min if (AccessFailedCount>=3) { lockOut = true; LockoutEnd=DateTime.Now.AddMinutes(5); } } }
UserLoginHistory
/// <summary> /// 用户登录记录实体类 /// </summary> public record UserLoginHistory { public long Id { get; init; } //主键Id(设置自增) public Guid? UserId { get; init; } //用户Id public PhoneNumber PhoneNumber { get; init; } //手机号 public DateTime CreatedDateTime { get; init; } //登录时间 public string Messsage { get; init; } //消息 /// <summary> /// 空构造函数,给EF用来返回实体的 /// </summary> public UserLoginHistory() { } /// <summary> /// 构造函数 /// </summary> /// <param name="userId"></param> /// <param name="phoneNumber"></param> /// <param name="message"></param> public UserLoginHistory(Guid? userId, PhoneNumber phoneNumber, string message) { this.UserId=userId; this.PhoneNumber=phoneNumber; this.CreatedDateTime = DateTime.Now; this.Messsage=message; } }
2. 值对象
手机号值对象:PhoneNumber
/// <summary>
/// 手机号【值对象】
/// </summary>
/// <param name="RegionCode">区号</param>
/// <param name="Number">手机号</param>
public record PhoneNumber(int RegionCode, string Number);
校验验证码的结果:CheckCodeResult
/// <summary>
/// 校验验证码的结果【值对象】
/// </summary>
public enum CheckCodeResult
{
OK, PhoneNumberNotFound, Lockout, CodeError
}
校验用户登录的结果:UserLoginResult
/// <summary>
/// 校验用户登录的结果【值对象】
/// </summary>
public enum UserLoginResult
{
OK, PhoneNumberNotFound, Lockout, NoPassword, PasswordError
}
3. 事件
(这里是不同的类库,安装【MediatR】即可,webapi项目则安装【MediatR.Extensions.Microsoft.DependencyInjection】)
UserLoginResultModel:登录结果的消息传递类
/// <summary>
/// 登录结果的消息传递类
/// </summary>
public record class UserLoginResultModel(PhoneNumber number, UserLoginResult result) : INotification;
4. 接口
A. 防腐层接口:ISmsCodeSender, 验证码接口 (因为验证码可能来源于多个服务商,所以这里抽离出来接口)
B. User的仓储接口:IUserRepository
注:这里不使用通用IBaseRepository接口,避免陷入“伪DDD”。
ISmsCodeSender
/// <summary> /// 验证码接口 /// (因为验证码可能来源于多个服务商,所以这里抽离出来接口) /// </summary> public interface ISmsCodeSender { /// <summary> /// 发送验证码 /// </summary> /// <param name="phoneNumber"></param> /// <param name="code"></param> /// <returns></returns> Task SendCodeAsync(PhoneNumber phoneNumber, string code); }
IUserRepository
/// <summary> /// User的仓储接口 /// </summary> public interface IUserRepository { /// <summary> /// 根据手机号查找用户 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> Task<UserInfo> FindOneAsync(PhoneNumber phoneNumber); /// <summary> /// 根据用户编号查找用户 /// </summary> /// <param name="userId"></param> /// <returns></returns> Task<UserInfo> FindOneAsync(Guid userId); /// <summary> /// 添加一条登录历史记录 /// </summary> /// <param name="phoneNumber"></param> /// <param name="msg"></param> /// <returns></returns> Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg); /// <summary> /// 保存验证码,存放到DB中 /// </summary> /// <param name="phoneNumber"></param> /// <param name="code"></param> /// <returns></returns> Task SavePhoneCodeAsync(PhoneNumber phoneNumber, string code); /// <summary> /// 校验验证码 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> Task<string> RetrievePhoneCodeAsync(PhoneNumber phoneNumber); /// <summary> /// 事件发布(发送消息) /// </summary> /// <param name="eventData"></param> /// <returns></returns> Task PublishEventAsync(UserLoginResultModel eventData); }
5 领域服务
UserDomainService,存在一些业务判断,但DB层次的操作还是在基础设施层处理。
A. 注入服务:验证码接口服务ISmsCodeSender、User仓储接口服务IUserRepository。
B. 编写相关方法:校验登录、发送验证码、校验验证码、失败重置、校验锁定、处理一次登录失败业务
UserDomainService
/// <summary> /// 用户领域服务 /// </summary> public class UserDomainService { private readonly IUserRepository repository; private readonly ISmsCodeSender smsSender; public UserDomainService(IUserRepository repository, ISmsCodeSender smsSender) { this.repository = repository; this.smsSender = smsSender; } /// <summary> /// 用户登录校验 /// </summary> /// <param name="phoneNum">电话</param> /// <param name="password">密码</param> /// <returns></returns> public async Task<UserLoginResult> CheckLoginAsync(PhoneNumber phoneNum, string password) { //1. 校验登录 var user = await repository.FindOneAsync(phoneNum); UserLoginResult result; if (user == null) { result = UserLoginResult.PhoneNumberNotFound;//找不到用户 } else if (IsLockOut(user)) { result = UserLoginResult.Lockout; //用户被锁定 } else if (user.HasPassword() == false) { result = UserLoginResult.NoPassword; //没设密码 } else if (user.CheckPassword(password)) { result = UserLoginResult.OK; //校验通过,登录成功 } else { result = UserLoginResult.PasswordError;//密码错误 } if (result == UserLoginResult.OK) { this.ResetLoginFail(user);//重置登录失败 } else if (result == UserLoginResult.PhoneNumberNotFound) { //表示该用户不存在,没有单独业务,但是一定不执行下面的LoginFail(user); } else { LoginFail(user);//处理1次登录失败 } //2. 事件发送 UserLoginResultModel eventItem = new(phoneNum, result); await repository.PublishEventAsync(eventItem); //3. 返回结果 return result; } /// <summary> /// 发送验证码 /// </summary> /// <param name="phoneNum"></param> /// <returns></returns> public async Task<UserLoginResult> SendCodeAsync(PhoneNumber phoneNum) { //1. 判断手机号是否存在 var user = await repository.FindOneAsync(phoneNum); if (user == null) { return UserLoginResult.PhoneNumberNotFound; } //2. 判断该用户是否被锁定 if (IsLockOut(user)) { return UserLoginResult.Lockout; } //3. 生成验证码→保存到redis中(也可以db中)→发送验证码 string code = Random.Shared.Next(1000, 9999).ToString(); await repository.SavePhoneCodeAsync(phoneNum, code); await smsSender.SendCodeAsync(phoneNum, code); return UserLoginResult.OK; } /// <summary> /// 校验验证码的准确性 /// </summary> /// <param name="phoneNum">手机号</param> /// <param name="code">验证码</param> /// <returns></returns> public async Task<CheckCodeResult> CheckCodeAsync(PhoneNumber phoneNum, string code) { var user = await repository.FindOneAsync(phoneNum); if (user == null) { return CheckCodeResult.PhoneNumberNotFound; } if (IsLockOut(user)) { return CheckCodeResult.Lockout; } string codeStr = await repository.RetrievePhoneCodeAsync(phoneNum); if (string.IsNullOrEmpty(codeStr)) { return CheckCodeResult.CodeError; } if (code == codeStr) { return CheckCodeResult.OK; } else { LoginFail(user); //处理一次登录失败 return CheckCodeResult.CodeError; } } /// <summary> /// 登录失败重置 /// </summary> /// <param name="user"></param> public void ResetLoginFail(UserInfo user) { user.LoginFail.Reset(); } /// <summary> /// 校验是否锁定 /// </summary> /// <param name="user"></param> /// <returns></returns> public static bool IsLockOut(UserInfo user) { return user.LoginFail.IsLockOut(); } /// <summary> /// 处理一次登录失败业务 /// </summary> /// <param name="user"></param> public static void LoginFail(UserInfo user) { user.LoginFail.LoginFail(); } }
三. 基础设施层搭建
【添加对 领域层(User.Domain) 的依赖】
1. 实体类配置
A. 安装程序集【 Microsoft.EntityFrameworkCore.SqlServer】
B. 三个配置文件:UserConfig、UserLoginFailConfig、UserLoginHistoryConfig
UserConfig
class UserConfig : IEntityTypeConfiguration<UserInfo> { public void Configure(EntityTypeBuilder<UserInfo> builder) { builder.ToTable("T_UserInfo"); //值对象的映射 builder.OwnsOne(x => x.PhoneNumber, nb => { nb.Property(x => x.RegionCode).HasMaxLength(5).IsUnicode(false); nb.Property(x => x.Number).HasMaxLength(20).IsUnicode(false); }); //成员变量映射到DB的写法 builder.Property("passWordHash").HasMaxLength(100).IsUnicode(false); //1对1关系映射 builder.HasOne(x => x.LoginFail).WithOne(x => x.User).HasForeignKey<UserLoginFail>(x => x.UserId); } }
UserLoginFailConfig
class UserLoginFailConfig : IEntityTypeConfiguration<UserLoginFail> { public void Configure(EntityTypeBuilder<UserLoginFail> builder) { builder.ToTable("T_UserLoginFail"); builder.Property("lockOut"); } }
UserLoginHistoryConfig
class UserLoginHistoryConfig : IEntityTypeConfiguration<UserLoginHistory> { public void Configure(EntityTypeBuilder<UserLoginHistory> builder) { builder.ToTable("T_UserLoginHistory"); //值对象的映射 builder.OwnsOne(x => x.PhoneNumber, nb => { nb.Property(x => x.RegionCode).HasMaxLength(5).IsUnicode(false); nb.Property(x => x.Number).HasMaxLength(20).IsUnicode(false); }); } }
2. DbContext编写
编写UserDbContext,详见代码
/// <summary>
/// User的DbContext
/// </summary>
public class UserDbContext : DbContext
{
public DbSet<UserInfo> User { get; private set; }
public DbSet<UserLoginHistory> UserLoginHistory { get; private set; }
public DbSet<UserLoginFail> UserLoginFail { get; private set; }
public UserDbContext(DbContextOptions<UserDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
//从当前代码中加载程序集
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
//等价
//modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}
3. 数据迁移
A. 安装程序集 【Microsoft.EntityFrameworkCore.Tools】
B. 新建一个类DbContextFactory, 实现IDesignTimeDbContextFactory<UserDbContext>接口,在重写方法里CreateDbContext里配置连接字符串即可。
(参考:https://learn.microsoft.com/zh-cn/ef/core/cli/dbcontext-creation?tabs=dotnet-core-cli)
C. 运行迁移脚本
【Add-Migration xxxx】 【Update-Database】
注:直接在UserDbContext中新增 OnConfiguring方法,里面直接配置数据库连接【这种写法已经废弃了】会报错
又报错:Unable to create an object of type 'UserDbContext'. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728
/// <summary>
/// DB迁移的时候使用
/// </summary>
public class DbContextFactory : IDesignTimeDbContextFactory<UserDbContext>
{
public UserDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<UserDbContext>();
optionsBuilder.UseSqlServer("Server=localhost;Database=DDD1;User ID=sa;Password=123456;");
return new UserDbContext(optionsBuilder.Options);
}
}
4. 防腐层接口实现
SmsCodeSender:短信验证码实现类,输出到控制台即可
/// <summary>
/// 短信验证码实现
/// </summary>
public class SmsCodeSender : ISmsCodeSender
{
private readonly ILogger<SmsCodeSender> logger;
public SmsCodeSender(ILogger<SmsCodeSender> logger)
{
this.logger = logger;
}
/// <summary>
/// 发送验证码
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="code">验证码</param>
/// <returns></returns>
public Task SendCodeAsync(PhoneNumber phoneNumber, string code)
{
//这里输出到控制台即可,真实项目调用短线服务商的Api接口
logger.LogInformation($"【{DateTime.Now}】:向{phoneNumber}发送验证码{code}");
return Task.CompletedTask;
}
}
5.仓储接口实现
用户仓储:UserRepository
A. 注入UserDbContext
注入IDistributedCache, 可以实现内存缓存和redis缓存无缝切换
注入IMediatR, 依赖的Domain层已经添加程序集了
B. 实现对应的方法,特别注意:这里涉及到增删改的方法,不直接savechange, 而是通过后面的工作单元来实现事务提交
代码分享:
/// <summary> /// User的仓储实现类 /// </summary> public class UserRepository : IUserRepository { private readonly IMediator mediator; private readonly UserDbContext context; private readonly IDistributedCache cache; public UserRepository(IMediator mediator, UserDbContext context, IDistributedCache cache) { this.mediator = mediator; this.context = context; this.cache = cache; } /// <summary> /// 根据手机号查找用户 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> public Task<Domain.UserInfo> FindOneAsync(PhoneNumber phoneNumber) { return context.Set<UserInfo>().Include(u => u.LoginFail).SingleOrDefaultAsync(ExpressionHelper.MakeEqual((UserInfo u) => u.PhoneNumber, phoneNumber)); } /// <summary> /// 根据用户编号查找用户 /// </summary> /// <param name="userId"></param> /// <returns></returns> public Task<Domain.UserInfo> FindOneAsync(Guid userId) { return context.Set<UserInfo>().Include(u => u.LoginFail).SingleOrDefaultAsync(u => u.Id == userId); } /// <summary> /// 添加一条登录历史记录 /// </summary> /// <param name="phoneNumber"></param> /// <param name="msg"></param> /// <returns></returns> public async Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg) { var user = await FindOneAsync(phoneNumber); if (user != null) { UserLoginHistory userLoginHistory = new(user.Id, phoneNumber, msg); context.UserLoginHistory.Add(userLoginHistory); } //不直接savechage,后面通过工作单元统一提交 } /// <summary> /// 保存验证码,存放到缓存中 5min过期 /// </summary> /// <param name="phoneNumber"></param> /// <param name="code"></param> /// <returns></returns> public Task SavePhoneCodeAsync(PhoneNumber phoneNumber, string code) { string fullNumber = phoneNumber.RegionCode + phoneNumber.Number; var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) //5min过期 }; cache.SetString($"MyPhoneCode_{fullNumber}", code, options); return Task.CompletedTask; } /// <summary> /// 校验验证码 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> public Task<string> RetrievePhoneCodeAsync(PhoneNumber phoneNumber) { //1. 获取该验证吗 string fullNumber = phoneNumber.RegionCode + phoneNumber.Number; string cacheKey = $"MyPhoneCode_{fullNumber}"; string code = cache.GetString(cacheKey); //2. 移除key //cache.Remove(cacheKey); return Task.FromResult(code); } /// <summary> /// 事件发布(发送消息) /// </summary> /// <param name="eventData"></param> /// <returns></returns> public Task PublishEventAsync(UserLoginResultModel eventData) { return mediator.Publish(eventData); } }
四. 应用层搭建
【添加对 领域层(User.Domain) 基础设施层(User.Infrastructure) 的依赖】
1. 测试
注入UserDbContext上下文,进行测试
2. 工作单元
A.背景
工作单元是由应用服务层来确定,其他层不应该调用SaveChangesAsync方法保存对数据的修改
B.实现原理
① 特性:用来标记需要执行工作单元逻辑,可以传入多个不同的dbContext UnitOfWork
② 过滤器:UnitOfWorkFilter
③ 全局注册过滤器
C. 测试: 使用 [UnitOfWork(typeof(UserDbContext))]
UnitOfWorkAttribute
/// <summary>
/// 标记工作单元的特性
/// (可以作用在方法或类上、同一个方法/类只能写一个、允许被继承)
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class UnitOfWorkAttribute : Attribute
{
public Type[] DbContextTypes { get; init; }
public UnitOfWorkAttribute(params Type[] dbContextTypes)
{
this.DbContextTypes = dbContextTypes;
foreach (var item in dbContextTypes)
{
if (!typeof(DbContext).IsAssignableFrom(item)) //item必须继承DbContext
{
throw new InvalidOperationException($"{item} 必须继承 DbContext");
}
}
}
}
UnitOfWorkFilter
/// <summary> /// 工作单元过滤器 /// </summary> public class UnitOfWorkFilter : IAsyncActionFilter { /// <summary> /// 获取[UnitOfWork]特性 /// 先从Controller获取,如果没有 /// </summary> /// <param name="actionDesc"></param> /// <returns></returns> private static UnitOfWorkAttribute GetUnitOfWorkAttr(ActionDescriptor actionDesc) { var caDesc = actionDesc as ControllerActionDescriptor; if (caDesc == null) { return null; } var uowAttr = caDesc.ControllerTypeInfo.GetCustomAttribute<UnitOfWorkAttribute>(); if (uowAttr != null) { return uowAttr; } else { return caDesc.MethodInfo.GetCustomAttribute<UnitOfWorkAttribute>(); } } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var unitAttr = GetUnitOfWorkAttr(context.ActionDescriptor); if (unitAttr == null) { await next(); //表示执行action中的业务, 这句话前后分别代表作用于action的前后 return; } List<DbContext> dbContextList = new(); foreach (var dbCtxType in unitAttr.DbContextTypes) { //用HttpContext的RequestServices,确保获取的是和请求相关的Scope实例 DbContext item = (DbContext)context.HttpContext.RequestServices.GetRequiredService(dbCtxType); dbContextList.Add(item); } var result = await next(); //执行action中的业务 if (result.Exception == null) { foreach (var item in dbContextList) { await item.SaveChangesAsync(); } } } }
3. 注入各种服务
A. 注册MediatR,需要安装程序集 【MediatR.Extensions.Microsoft.DependencyInjection】
B. 注册User领域服务
C. 注册短信接口服务
D. 注册User仓促服务
E. 注册分布式缓存服务,支持redis 【Caching.CSRedis】
//注册EF上下文
builder.Services.AddDbContext<UserDbContext>(b => {
string connStr = builder.Configuration.GetConnectionString("SQLServerStr");
b.UseSqlServer(connStr);
});
//注册全局过滤器
builder.Services.Configure<MvcOptions>(opt => {
opt.Filters.Add<UnitOfWorkFilter>();
});
//注册MediatR
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
//注册User领域服务
builder.Services.AddScoped<UserDomainService>();
//注册短信接口服务
builder.Services.AddScoped<ISmsCodeSender, SmsCodeSender>();
//注册User仓促服务
builder.Services.AddScoped<IUserRepository, UserRepository>();
//注册分布式缓存(内存 or redis)
//builder.Services.AddDistributedMemoryCache(); //内存
//redis
var csredis = new CSRedis.CSRedisClient(builder.Configuration["RedisStr"]);
builder.Services.AddSingleton<IDistributedCache>(new Microsoft.Extensions.Caching.Redis.CSRedisCache(csredis));
4. 事件接收
UserLoginEventHandle用来处理登录事件,记录一条登录结果
/// <summary>
/// 处理用户登录事件
/// (记录一条登录记录)
/// </summary>
public class UserLoginEventHandle : INotificationHandler<UserLoginResultModel>
{
private readonly IUserRepository userRepository;
public UserLoginEventHandle(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public Task Handle(UserLoginResultModel notification, CancellationToken cancellationToken)
{
var result = notification.result;
var phoneNum = notification.number;
string msg;
switch (result)
{
case UserLoginResult.OK:
msg = $"登陆成功";
break;
case UserLoginResult.PhoneNumberNotFound:
msg = $"登陆失败,因为用户不存在";
break;
case UserLoginResult.PasswordError:
msg = $"登陆失败,密码错误";
break;
case UserLoginResult.NoPassword:
msg = $"登陆失败,没有设置密码";
break;
case UserLoginResult.Lockout:
msg = $"登陆失败,被锁定";
break;
default:
throw new NotImplementedException();
}
Console.WriteLine($"领域事件:【{msg}】");
return userRepository.AddNewLoginHistoryAsync(phoneNum, msg);
}
}
5. Api接口
A. 用户相关
① 新增用户(AddUser):T_UserInfo表 和 T_UserLoginFail表 同时添加1条记录。
② 获取所有信息(GetAll)
③ 修改密码(ChangePassword)
④ 解除登录锁定(Unlock)
代码分享:
/// <summary> /// 用户控制器【测试通过】 /// </summary> [Route("api/[controller]/[action]")] [ApiController] public class UserController : ControllerBase { private readonly UserDbContext db; private readonly UserDomainService domainService; private readonly IUserRepository repository; public UserController(UserDbContext db, UserDomainService domainService, IUserRepository repository) { this.db = db; this.domainService = domainService; this.repository = repository; } /// <summary> /// 新增用户 /// (对于增删改查等这种简单的业务场景,我们没必要拘泥于DDD的原则。这也是洋葱架构的优点) /// </summary> /// <param name="param"></param> /// <returns></returns> [HttpPost] [UnitOfWork(typeof(UserDbContext))] public async Task<IActionResult> AddUser(LoginByPhoneAndPwdParam param) { UserInfo user = new(param.PhoneNumber, param.Pwd); await db.User.AddAsync(user); //await db.SaveChangesAsync(); return Ok("ok"); } /// <summary> /// 获取所有用户信息 /// </summary> /// <returns></returns> [HttpGet] public async Task<IActionResult> GetAll() { var users = await db.User.ToListAsync(); return Ok(users); } /// <summary> /// 修改密码 /// </summary> /// <param name="req"></param> /// <returns></returns> [HttpPut] [UnitOfWork(typeof(UserDbContext))] public async Task<IActionResult> ChangePassword(ChangePasswordParam req) { var user = await repository.FindOneAsync(req.Id); if (user == null) { return NotFound(); } user.ChangePassword(req.newPwd); return Ok("成功"); } /// <summary> /// 解除登录锁定 /// </summary> /// <param name="id">用户编号</param> /// <returns></returns> [HttpPut] [UnitOfWork(typeof(UserDbContext))] public async Task<IActionResult> Unlock(Guid id) { var user = await repository.FindOneAsync(id); if (user == null) { return NotFound(); } domainService.ResetLoginFail(user); return Ok("成功"); } }
B. 登录相关
① 校验登录(LoginByPhoneAndPwd)
② 发送验证码(SendCodeByPhone)
③ 校验验证码(CheckCode): 根据手机号+前缀组成key,去缓存中获取value,然后直接删除这个key. 如果value和发送的code相同的,验证码正确;
如果code=null,肯定校验不过,出现的原因如下:
a.第一次校验通过后,删除了这个key
b.这个key在缓存中已经过期了,所以缓存中不存在
c.手机号错了,所以组成的key在缓存中不存在
代码分享:
/// <summary> /// 登录控制器 /// </summary> [Route("api/[controller]/[action]")] [ApiController] public class LoginController : ControllerBase { private readonly UserDomainService domainService; private readonly IDistributedCache cache; public LoginController(UserDomainService domainService, IDistributedCache cache) { this.domainService = domainService; this.cache = cache; } /// <summary> /// 通过手机号和密码登录【测试通过】 /// </summary> /// <param name="param"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> [HttpPost] [UnitOfWork(typeof(UserDbContext))] public async Task<ActionResult> LoginByPhoneAndPwd(LoginByPhoneAndPwdParam param) { if (param.Pwd.Length < 3) { return BadRequest("密码的长度不能小于3"); } var phoneNum = param.PhoneNumber; var result = await domainService.CheckLoginAsync(phoneNum, param.Pwd); switch (result) { case UserLoginResult.OK: return Ok("登录成功"); case UserLoginResult.PhoneNumberNotFound: return BadRequest("该手机号不存在"); //避免泄密,不能404 case UserLoginResult.Lockout: return BadRequest("用户被锁定,请稍后再试"); case UserLoginResult.NoPassword: return BadRequest("该用户没有密码,属于异常用户"); case UserLoginResult.PasswordError: return BadRequest("密码错误"); default: throw new NotImplementedException(); } } /// <summary> /// 发送验证码【测试通过】 /// </summary> /// <param name="req"></param> /// <returns></returns> [HttpPost] public async Task<IActionResult> SendCodeToPhone(SendCodeByPhoneParam req) { var result = await domainService.SendCodeAsync(req.PhoneNumber); switch (result) { case UserLoginResult.OK: return Ok("验证码已发出"); case UserLoginResult.Lockout: return BadRequest("用户被锁定,请稍后再试"); default: return BadRequest("请求错误");//避免泄密,不说细节 } } /// <summary> /// 校验验证码是否正确【测试通过】 /// </summary> /// <param name="req"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> [HttpPost] [UnitOfWork(typeof(UserDbContext))] public async Task<IActionResult> CheckCode(CheckCodeParam req) { var result = await domainService.CheckCodeAsync(req.PhoneNumber, req.Code); switch (result) { case CheckCodeResult.OK: return Ok("验证码正确,校验通过"); case CheckCodeResult.PhoneNumberNotFound: return BadRequest("请求错误");//避免泄密 case CheckCodeResult.Lockout: return BadRequest("用户被锁定,请稍后再试"); case CheckCodeResult.CodeError: return BadRequest("验证码错误"); default: throw new NotImplementedException(); } } [HttpGet] public string TestCache(string key) { return cache.GetString(key); } }
五. 测试
访问相关接口进行测试:
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。