前言

时隔五个月,终于又来更新 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<Comment>? 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; } /// <summary>
/// 是否需要审核
/// </summary>
public bool IsNeedAudit { get; set; } = false; /// <summary>
/// 原因
/// <para>如果验证不通过的话,可能会附上原因</para>
/// </summary>
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 中,封装一个方法,使用正则表达式检查邮箱地址。

/// <summary>
/// 检查邮箱地址是否有效
/// </summary>
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) 开发友情链接相关接口

/// <summary>
/// 发送邮箱验证码
/// <returns>生成随机验证码</returns>
/// <param name="mock">只生成验证码,不发邮件</param>
/// </summary>
public async Task<string> SendOtpMail(string email, bool mock = false) {
var otp = Random.Shared.NextInt64(1000, 9999).ToString(); var sb = new StringBuilder();
sb.AppendLine($"<p>欢迎访问StarBlog!验证码:{otp}</p>");
sb.AppendLine($"<p>如果您没有进行任何操作,请忽略此邮件。</p>"); 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信息。

/// <summary>
/// 获取邮件验证码
/// </summary>
[HttpGet("[action]")]
public async Task<ApiResponse> 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 参数,可以控制验证通过后是否清除这个验证码。

/// <summary>
/// 验证一次性密码
/// </summary>
/// <param name="clear">验证通过后是否清除</param>
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;
}

后端接口

接口代码如下。

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

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

/// <summary>
/// 根据邮箱和验证码,获取匿名用户信息
/// </summary>
[HttpGet("[action]")]
public async Task<ApiResponse> 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 组件上

<el-form :model="form" status-icon :rules="formRules" ref="form" class="my-3">

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

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

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<ApiResponse<Comment>> 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>(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<IEnumerable<Word>>(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<Comment> 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<ApiResponse<Comment>> Accept([FromRoute] string id, [FromBody] CommentAcceptDto dto) {
var item = await _commentService.GetById(id);
if (item == null) return ApiResponse.NotFound();
return new ApiResponse<Comment>(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 里完成一个简单可用的评论系统。

参考资料

基于.NetCore开发博客项目 StarBlog - (30) 实现评论系统的更多相关文章

  1. 基于.NetCore开发博客项目 StarBlog - (16) 一些新功能 (监控/统计/配置/初始化)

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  2. 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 ... 基于. ...

  3. 基于.NetCore开发博客项目 StarBlog - (3) 模型设计

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  4. 基于.NetCore开发博客项目 StarBlog - (4) markdown博客批量导入

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  5. 基于.NetCore开发博客项目 StarBlog - (5) 开始搭建Web项目

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  6. 基于.NetCore开发博客项目 StarBlog - (6) 页面开发之博客文章列表

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  7. 基于.NetCore开发博客项目 StarBlog - (7) 页面开发之文章详情页面

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  8. 基于.NetCore开发博客项目 StarBlog - (8) 分类层级结构展示

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  9. 基于.NetCore开发博客项目 StarBlog - (9) 图片批量导入

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  10. 基于.NetCore开发博客项目 StarBlog - (10) 图片瀑布流

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

随机推荐

  1. Java单元测试及常用语句

    1 前言 编写Java单元测试用例,即把一段复杂的代码拆解成一系列简单的单元测试用例,并且无需启动服务,在短时间内测试代码中的处理逻辑.写好Java单元测试用例,其实就是把"复杂问题简单化, ...

  2. 一个 Java 接口快速开发框架:magic-api

    一.简介 magic-api是一个基于Java的接口快速开发框架,编写接口将通过magic-api提供的UI界面完成,自动映射为HTTP接口.无需定义Controller.Service.Dao.Ma ...

  3. 全是中文的txt文件查找特定字符并输出该行到新文件

    tangshi.txt文件为全为汉唐诗 在该文件中查找指定字符 codecs库为打开中文文件的库,详情自行知乎 tangshi.txt大概十几万行,需要该文件练手的同学下方评论 要点:更改文件字符编码 ...

  4. 使用vscodep快速编写markdown

    写在前面 这是一篇基于 vscode 配置,用于书写 markdown 的文章 为了方便快速书写 markdown 真想使用一些便捷的快捷键去生成一些自己常用的格式或者是模版,于是自己基于自己的个人习 ...

  5. git升级编译安装

    一.删除旧版本git 方法一. yum remove git -y (centos环境) apt-get remove git -y (Ubuntu环境) 方法二. which git [root@p ...

  6. 【Python爬虫实战】爬虫封你ip就不会了?ip代理池安排上

    前言 在进行网络爬取时,使用代理是经常遇到的问题.由于某些网站的限制,我们可能会被封禁或者频繁访问时会遇到访问速度变慢等问题.因此,我们需要使用代理池来避免这些问题.本文将为大家介绍如何使用IP代理池 ...

  7. Springboot简单功能示例-3 实现基本登录验证

    springboot-sample 介绍 springboot简单示例 跳转到发行版 查看发行版说明 软件架构(当前发行版使用) springboot hutool-all 非常好的常用java工具库 ...

  8. it 作形式主语:It's no good doing sth.

    It's no good doing sth. 这个 句型其实是一个省 略介词 in 的句型,完整形式是 It's no good in doing sth. 其中, good 是形容词,和介词 in ...

  9. Sell Pigs 题解

    Sell Pigs 双倍经验 题目大意 有 \(n\) 个顾客前来买猪,共有 \(m\) 个猪圈,每个顾客携带着某一些猪圈的钥匙,需要买一定数量的猪.在顾客买完后,我们可以将打开的猪圈中的猪随意移动, ...

  10. inventory 主机清单

    inventory 主机清单 //Inventory支持对主机进行分组,每个组内可以定义多个主机,每个主机都可以定义在任何一个或多个主机组内. //如果是名称类似的主机,可以使用列表的方式标识各个主机 ...