• 基于.NetCore开发博客项目 StarBlog - (30) 实现评论系统


    前言#

    时隔五个月,终于又来更新 StarBlog 系列了~

    这次是呼声很大的评论系统。

    由于涉及的代码量比较大,所以本文不会贴出所有代码,只介绍关键逻辑,具体代码请同学们自行查看 GitHub 仓库。

    博客前台以及后端涉及的代码主要在以下文件:

    • StarBlog.Web/Services/CommentService.cs
    • StarBlog.Web/Apis/Comments/CommentController.cs
    • StarBlog.Web/Views/Blog/Widgets/Comment.cshtml
    • StarBlog.Web/wwwroot/js/comment.js

    管理后台的代码在以下文件:

    • src/views/Comment/Comments.vue

    实现效果#

    在开始之前,先来看看实现的效果吧。

    博客前台#

    讨论区的这部分UI使用 Vue 来驱动,为了开发效率还引入了 ElementUI 的组件,看起来风格跟博客原本的 Bootstrap 不太一样,不过还挺和谐的。

    无须登录即可发表或回复评论,但需要输入邮箱地址并接收邮件验证码。

    为了构建文明和谐的网络环境,发表评论之后会由小管家自动审核,审核通过才会展示。

    如果小管家自动审核没有通过,会进入人工审核流程。

    管理后台#

    管理后台可以设置评论的审核通过或拒绝。

    模型设计#

    功能介绍前面都说了,不再赘述,直接从代码开始讲起。

    这个功能新增了两个实体类,分别是 CommentAnonymousUser

    评论实体类的代码如下,可以看到除了 AnonymousUser 的引用,我还预留了一个 User 属性,目前博客前台是没有做登录功能的,预留这个属性可以方便以后的登录用户进行评论。

    public class Comment : ModelBase {
      [Column(IsIdentity = false, IsPrimary = true)]
      public string Id { get; set; }
    
      public string? ParentId { get; set; }
      public Comment? Parent { get; set; }
      public List? Comments { get; set; }
    
      public string PostId { get; set; }
      public Post Post { get; set; }
    
      public string? UserId { get; set; }
      public User? User { get; set; }
    
      public string? AnonymousUserId { get; set; }
      public AnonymousUser? AnonymousUser { get; set; }
    
      public string? UserAgent { get; set; }
      public string Content { get; set; }
      public bool Visible { get; set; }
    
      /// 
      /// 是否需要审核
      /// 
      public bool IsNeedAudit { get; set; } = false;
    
      /// 
      /// 原因
      /// 如果验证不通过的话,可能会附上原因
      /// 
      public string? Reason { get; set; }
    }
    

    匿名用户实体类,简简单单的,需要访客填写的就三个字段,IP地址自动记录。

    public class AnonymousUser : ModelBase {
      public string Id { get; set; }
      public string Name { get; set; }
      public string Email { get; set; }
      public string? Url { get; set; }
      public string? Ip { get; set; }
    }
    

    前端接口封装#

    前端使用 axios 方便接口调用,当然使用 ES5 原生的 fetch 函数也可以,不过会多一些代码,懒是第一生产力。

    使用 Promise 来包装返回值,便于使用 ES5 的 async/await 语法,获得跟C#类似的异步开发体验。

    因为篇幅关系,本文无法列举所有接口封装代码,只举两个典型例子。

    以下是获取匿名用户的接口,作为 GET 方法的例子。

    getAnonymousUser(email, otp) {
      return new Promise((resolve, reject) => {
        axios.get(`/Api/Comment/GetAnonymousUser?email=${email}&otp=${otp}`)
          .then(res => resolve(res.data))
          .catch(res => resolve(res.response.data))
      })
    }
    

    以下是提交评论的接口,作为 POST 方法的例子。

    submitComment(data) {
      return new Promise((resolve, reject) => {
        axios.post(`/Api/Comment`, {...data})
          .then(res => resolve(res.data))
          .catch(res => resolve(res.response.data))
      })
    }
    

    OK,这是俩最简单的例子,没有进行任何数据处理。

    生成邮件验证码#

    通常使用哈希表类的数据结构来存储这种数据,本项目中,我使用 .NetCore 自带的 MemoryCache 来存储验证码,除此之外,直接使用 Dictionary 或者 Redis 都是可选项。

    需要在发送邮件的时候将邮箱地址与对应的验证码存入缓存,然后在验证的时候取出,验证通过后删除这一条记录。

    首先在 Program.cs 中注册服务

    builder.Services.AddMemoryCache();
    

    检查邮箱地址是否有效#

    CommentService.cs 中,封装一个方法,使用正则表达式检查邮箱地址。

    /// 
    /// 检查邮箱地址是否有效
    /// 
    public static bool IsValidEmail(string email) {
      if (string.IsNullOrEmpty(email) || email.Length < 7) {
        return false;
      }
    
      var match = Regex.Match(email, @"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+");
      var isMatch = match.Success;
      return isMatch;
    }
    

    发送邮箱验证码#

    为了方便发送邮件,我封装了 EmailService,其中的发送验证码的代码如下。

    生成四位数的验证码直接使用 Random 生成一个在 1000-9999 之间的随机数即可。

    关于发邮件,在友情链接的那篇文章里有介绍: 基于.NetCore开发博客项目 StarBlog - (28) 开发友情链接相关接口

    /// 
    /// 发送邮箱验证码
    /// 生成随机验证码
    /// 只生成验证码,不发邮件
    /// 
    public async Task<string> SendOtpMail(string email, bool mock = false) {
      var otp = Random.Shared.NextInt64(1000, 9999).ToString();
    
      var sb = new StringBuilder();
      sb.AppendLine($"

    欢迎访问StarBlog!验证码:{otp}

    "
    ); sb.AppendLine($"

    如果您没有进行任何操作,请忽略此邮件。

    "
    ); if (!mock) { await SendEmailAsync( "[StarBlog]邮箱验证码", sb.ToString(), email, email ); } return otp; }

    检查是否有验证码的缓存,没有的话生成一个并发送邮件,然后存入缓存,这里我设置了过期时间是5分钟。

    public async Task<(bool, string?)> GenerateOtp(string email, bool mock = false) {
      var cacheKey = $"comment-otp-{email}";
      var hasCache = _memoryCache.TryGetValue<string>(cacheKey, out var existingValue);
      if (hasCache) return (false, existingValue);
    
      var otp = await _emailService.SendOtpMail(email, mock);
      _memoryCache.Set<string>(cacheKey, otp, new MemoryCacheEntryOptions {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
      });
    
      return (true, otp);
    }
    

    接口#

    最后在 Controller 里实现这个接口。

    这里只考虑了三种情况

    • 邮箱地址错误
    • 发送邮件成功
    • 上一个验证码在有效期,不发送邮件

    其实还有一种情况是发送邮件失败,不过我没有写在这个接口里,如果发送失败会抛出错误,然后被全局的错误处理器拦截到并返回500信息。

    /// 
    /// 获取邮件验证码
    /// 
    [HttpGet("[action]")]
    public async Task GetEmailOtp(string email) {
      if (!CommentService.IsValidEmail(email)) {
        return ApiResponse.BadRequest("提供的邮箱地址无效");
      }
    
      var (result, _) = await _commentService.GenerateOtp(email);
      return result
        ? ApiResponse.Ok("发送邮件验证码成功,五分钟内有效")
        : ApiResponse.BadRequest("上一个验证码还在有效期内,请勿重复请求验证码");
    }
    

    检查验证码与获取匿名用户#

    前面在「模型设计」部分里有说到,未登录和已登录用户都可以发表评论(当然目前还没有提供其他用户登录的功能),本文只设计了未登录用户(即匿名用户)的评论发表流程。

    在用户发送邮件验证码,并且验证码校验通过之后,可以通过接口获取到邮箱地址对应的匿名用户信息,这样不会让访客需要多次重复输入,同时也可以在下一次评论提交时修改这些信息。

    核对验证码#

    我在 CommentService.cs 中封装了以下方法用于核对验证码,并且增加了 clear 参数,可以控制验证通过后是否清除这个验证码。

    /// 
    /// 验证一次性密码
    /// 
    /// 验证通过后是否清除
    public bool VerifyOtp(string email, string otp, bool clear = true) {
      var cacheKey = $"comment-otp-{email}";
      _memoryCache.TryGetValue<string>(cacheKey, out var value);
    
      if (otp != value) return false;
    
      if (clear) _memoryCache.Remove(cacheKey);
      return true;
    }
    

    后端接口#

    接口代码如下。

    这里把生成新验证码的代码注释掉了,原本我设计的是获取匿名用户信息和发评论都需要验证码,所以匿名用户信息获取之后需要重新生成一个验证码(但不发邮件)给前端,然后前端更新一下暂存的验证码。

    但是我发现这样有点过度设计了,而且这种做法会给访客带来一定的困扰(提交的验证码和邮件收到的不是同一个),于是把这一个功能简化了一下,但逻辑还保留着。

    /// 
    /// 根据邮箱和验证码,获取匿名用户信息
    /// 
    [HttpGet("[action]")]
    public async Task GetAnonymousUser(string email, string otp) {
      if (!CommentService.IsValidEmail(email)) return ApiResponse.BadRequest("提供的邮箱地址无效");
    
      var verified = _commentService.VerifyOtp(email, otp, clear: false);
      if (!verified) return ApiResponse.BadRequest("验证码无效");
    
      var anonymous = await _commentService.GetAnonymousUser(email);
      // 暂时不使用生成新验证码的功能,避免用户体验割裂
      // var (_, newOtp) = await _commentService.GenerateOtp(email, true);
    
      return ApiResponse.Ok(new {
        AnonymousUser = anonymous,
        NewOtp = otp
      });
    }
    

    前端逻辑#

    当访客在讨论区界面填写了验证码之后,会触发 change 事件,执行以下 JavaScript 代码。(篇幅关系做了简化)

    当用户输入的验证码长度符合要求之后,会请求后端接口校验这个验证码是否正确,验证码正确的话后端会同时返回这个邮箱地址对应的匿名用户信息。

    之后原本锁着的几个输入框也能交互了,或者也可以点击「回复」按钮对其他人的评论进行回复。

    async handleEmailOtpChange(value) {
      console.log('handleEmailOtpChange', value)
      if (this.form.email?.length === 0 || value.length < 4) return
      
      // 设置 UI 加载状态
      this.[对应的UI组件] = true
      
      // 校验OTP & 获取匿名用户
      let res = await this.getAnonymousUser(this.form.email, value)
    
      if (res.successful) {
        if (res.data.anonymousUser) {
          this.form.userName = res.data.anonymousUser.name
          this.form.url = res.data.anonymousUser.url
        }
        this.form.emailOtp = res.data.newOtp
        // 锁住邮箱和验证码,不用编辑了
        this.[对应的UI组件] = true
        // 开启编辑用户名、网址、内容、回复
        this.[对应的UI组件] = false
      } else {
        this.$message.error(res.message)
      }
      this.userNameLoading = false
      this.urlLoading = false
    }
    

    提交评论#

    这部分是比较复杂的,一步步来介绍

    表单验证#

    利用 ElementUI 提供的表单验证功能,虽然是比较老的组件库了,但这块的功能还是不错的。

    首先定义表单规则。

    formRules: {
      userName: [
        {required: true, message: '请输入用户名称', trigger: 'blur'},
        {min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur'}
      ],
      email: [
        {required: true, message: '请输入邮箱', trigger: 'blur'},
        {type: 'email', message: '邮箱格式不正确'}
      ],
      emailOtp: [
        {required: true, message: '请输入邮箱验证码', trigger: 'change'},
        {len: 4, message: '长度 4 个字符', trigger: 'change'}
      ],
      url: [
        {type: 'url', message: `请输入正确的url`, trigger: 'blur'},
      ],
      content: [
        {required: true, message: '请输入评论内容', trigger: 'blur'},
        {min: 1, max: 300, message: '长度 在 1 到 300 个字符', trigger: 'blur'},
        {whitespace: true, message: '评论内容只存在空格', trigger: 'blur'},
      ]
    }
    

    然后将这些定好的规则绑定到 form 组件上

    
    

    在提交的时候调用以下代码进行表单验证。

    验证成功可以在其回调里执行接口调用等操作。

    this.$refs.form.validate(async (valid) => {
      if (valid) {}
    }
    

    发送请求#

    表单验证通过之后调用前面封装好的接口提交评论。

    如果评论发表失败,则显示错误信息。

    如果评论发表成功,显示信息之后,清空整个表单,但保留邮件地址,便于访客提交下一个评论。

    最后无论成功与否,都会刷新评论列表。

    async handleSubmit() {
      this.$refs.form.validate(async (valid) => {
        if (valid) {
          this.submitLoading = true
          let res = await this.submitComment(this.form)
          if (res.successful) {
            this.$message.success(res.message)
            let email = `${this.form.email}`
            this.handleReset()
            this.form.email = email
          } else this.$message.error(res.message)
          this.submitLoading = false
          await this.getComments()
        }
      })
    }
    

    接口设计#

    前端的说完了,来到了后端部分,以下代码做了这些事:

    • 核对验证码
    • 获取匿名用户
    • 生成新评论
    • 小管家自动审核(敏感词检测)
    • 保存评论并返回结果
    [HttpPost]
    public async Task> Add(CommentCreationDto dto) {
      if (!_commentService.VerifyOtp(dto.Email, dto.EmailOtp)) {
        return ApiResponse.BadRequest("验证码无效");
      }
    
      var anonymousUser = await _commentService.GetOrCreateAnonymousUser(
        dto.UserName, dto.Email, dto.Url,
        HttpContext.GetRemoteIPAddress()?.ToString().Split(":")?.Last()
      );
    
      var comment = new Comment {
        ParentId = dto.ParentId,
        PostId = dto.PostId,
        AnonymousUserId = anonymousUser.Id,
        UserAgent = Request.Headers.UserAgent,
        Content = dto.Content
      };
    
      string msg;
      if (_filter.CheckBadWord(dto.Content)) {
        comment.IsNeedAudit = true;
        comment.Visible = false;
        msg = "小管家发现您可能使用了不良用语,该评论将在审核通过后展示~";
      }
      else {
        comment.Visible = true;
        msg = "评论由小管家审核通过,感谢您参与讨论~";
      }
    
      comment = await _commentService.Add(comment);
    
      return new ApiResponse(comment) {
        Message = msg
      };
    }
    

    小管家审核#

    说是评论审核,实际上就是敏感词检测,本项目使用 DFA(确定性有限状态自动机)来实现检测。

    本来这部分都可以单独写一篇文章介绍了,不过考虑到都写到这了,也简单介绍一下好了。

    DFA即确定性有限状态自动机,用于实现状态之间的自动转移。 与DFA对应的还有一个NFA非确定有限状态自动机,二者统称为有限自动状态机FSM。它们的主要区别在于 从一个状态转移的时候是否能唯一确定下一个状态。NFA在转移的时候往往不是转移到某一个确定状态,而是某个状态集合,其中的任一状态都可作为下一个状态,而DFA则是确定的。

    DFA的组成#

    • 一个非空有限状态集合 Q
    • 一个输入集合 E
    • 状态转移函数 f
    • 初始状态 q0 为Q的一个元素
    • 终止状态集合 Z 为Q的子集

    一个DFA可以写成 M=(Q, E, f, q0, Z)

    如何使用DFA实现敏感词过滤算法#

    现假设有NND, CNM, MLGB三个敏感词,则:

    Q = {N, NN, NND, C, CN, CNM, M, ML, MLG, MLGB}

    以所有敏感词的组成作为状态集合,状态机只需在这些状态之间转移即可

    E = {B, C, D, G, L, N, M}, 以所有组成敏感词的单个字符作为输入集合,状态机只需识别构成敏感词的字符。

    qo = null 初始状态为空,为空的初态可以转移到任意状态

    Z = {NND, CNM, MLGB} 识别到任意一个敏感词, 状态转移就可以终止了。

    那么f 就可以是一个 读入一个字符后查询是否为Q中的状态进而转移的函数,则转移过程为

    f(null, N) = N, f(N, N) = NN, f(NN, D) = NND

    f(null, C) = C, f(C, N) = CN, f(CN, M) = CNM

    f(null, M) = M , f(M, L) = ML, f(ML, G) = MLG, f(MLG, B) = MLGB

    使用方式#

    具体的实现代码比较长,我就不贴了,本文的篇幅已经严重超长了…

    总之我把这部分代码封装好了,在 CodeLab.Share 这个 nuget 包里,直接调用就完事了。

    所以可以看到我在 StarBlog 项目里写了一个 TempFilterService

    因为封装好的 StopWordsToolkit 有很多功能,不仅可以检测敏感词,还可以自动替换成星号啥的,当时在做这个功能的时候还想着要不要加点奇奇怪怪的功能,所以叫把这个 service 加了个 temp 的前缀。

    public class TempFilterService {
        private readonly StopWordsToolkit _toolkit;
    
        public TempFilterService() {
            var words = JsonSerializer.Deserialize>(File.ReadAllText("words.json"));
            _toolkit = new StopWordsToolkit(words!.Select(a => a.Value));
        }
    
        public bool CheckBadWord(string word) {
            return _toolkit.CheckBadWord(word);
        }
    }
    

    这里初始化的时候需要 words.json 这个敏感词库文件,为了网络环境的文明和谐,本项目的开源代码里不能提供,需要的同学可以自行搜集。

    格式是这样的

    [
      {
        "Id": 1,
        "Value": "小可爱",
        "Tag": "暴力"
      },
      {
        "Id": 2,
        "Value": "河蟹",
        "Tag": "广告"
      }
    ]
    

    人工审核#

    当评论被小管家判定有敏感词的时候,就会标记 IsNeedAudit=true 进入人工审核流程。

    就是 AcceptReject 这俩方法。

    public async Task Accept(Comment comment, string? reason = null) {
      comment.Visible = true;
      comment.IsNeedAudit = false;
      comment.Reason = reason;
      await _commentRepo.UpdateAsync(comment);
      return comment;
    }
    

    对应的接口

    [Authorize]
    [HttpPost("{id}/[action]")]
    public async Task> Accept([FromRoute] string id, [FromBody] CommentAcceptDto dto) {
      var item = await _commentService.GetById(id);
      if (item == null) return ApiResponse.NotFound();
      return new ApiResponse(await _commentService.Accept(item, dto.Reason));
    }
    

    管理后台#

    接下来会有专门的一个系列介绍基于 Vue 的管理后台开发,所以本文不会花太多篇幅介绍,只简单记录一点。

    原本我使用了 Dialog 来让用户输入通过或拒绝某个评论审核的原因,后面发现 ElementUI 提供了 prompt 功能,可以弹出一个简单的输入框。

    所以拒绝某个评论的代码如下

    handleReject(item) {
      this.$prompt('请输入原因', '审核评论 - 补充原因', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
      }).then(({value}) => {
        this.$api.comment.reject(item.id, value)
          .then(res => {
          this.$message.success('操作成功!')
        })
          .catch(res => {
          console.error(res)
          this.$message.warning(`操作失败!${res.message}`)
        })
          .finally(() => this.loadData())
      }).catch(() => {
      })
    }
    

    小结#

    评论不是一个简单的功能,本文仅仅介绍评论系统开发中的关键步骤和代码,就已经有了这么长的篇幅,要做得完善好用需要考虑方方面面的细节,经过一段时间的努力,我已经初步在 StarBlog 里完成一个简单可用的评论系统。

    参考资料#

  • 相关阅读:
    吴恩达机器学习系列课程笔记——第十四章:降维(Dimensionality Reduction)
    【Golang星辰图】Go语言之光照耀数据科学:揭开强大库的神秘面纱
    【Python机器学习】零基础掌握BaggingClassifier集成学习
    LeetCode中等题之前K个高频单词
    所有专栏博客汇总列表
    运筹说 第82期 | 算法介绍之图与网络分析(二)
    JQuery笔记
    赫尔辛基交通安全改善项目部署Velodyne Lidar智能基础设施解决方案
    【Java】集合 之 使用List
    springboot属性注入增强(一)背景/需求
  • 原文地址:https://www.cnblogs.com/deali/p/17910094.html