第四节: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;
    }


}
View Code

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);
        }
    }



}
View Code

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;
    }

}
View Code

 

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);
}
View 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);

}
View Code

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();
    }
}
View Code

 

三. 基础设施层搭建

【添加对 领域层(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);
    }
}
View Code

UserLoginFailConfig

class UserLoginFailConfig : IEntityTypeConfiguration<UserLoginFail>
{
    public void Configure(EntityTypeBuilder<UserLoginFail> builder)
    {
        builder.ToTable("T_UserLoginFail");
        builder.Property("lockOut");
    }
}
View Code

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);
        });
    }
}
View Code

 

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);
    }


}
View Code

 

四. 应用层搭建

【添加对 领域层(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();
            }
        }

    }
}
View Code

 

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("成功");
    }




}
View Code

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);
    }




}
View Code

 

五. 测试 

 访问相关接口进行测试:

 

 

 

 

 

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2022-09-25 21:22  Yaopengfei  阅读(1016)  评论(1编辑  收藏  举报