基于.NetCore开发博客项目 StarBlog - (30) 实现评论系统
前言
時隔五個月,終于又來更新 StarBlog 系列了~
這次是呼聲很大的評論系統。
由于涉及的代碼量比較大,所以本文不會貼出所有代碼,只介紹關鍵邏輯,具體代碼請同學們自行查看 GitHub 倉庫。
博客前臺以及后端涉及的代碼主要在以下文件:
StarBlog.Web/Services/CommentService.csStarBlog.Web/Apis/Comments/CommentController.csStarBlog.Web/Views/Blog/Widgets/Comment.cshtmlStarBlog.Web/wwwroot/js/comment.js
管理后臺的代碼在以下文件:
src/views/Comment/Comments.vue
實現效果
在開始之前,先來看看實現的效果吧。
博客前臺
討論區的這部分UI使用 Vue 來驅動,為了開發效率還引入了 ElementUI 的組件,看起來風格跟博客原本的 Bootstrap 不太一樣,不過還挺和諧的。
無須登錄即可發表或回復評論,但需要輸入郵箱地址并接收郵件驗證碼。
為了構建文明和諧的網絡環境,發表評論之后會由小管家自動審核,審核通過才會展示。
如果小管家自動審核沒有通過,會進入人工審核流程。
管理后臺
管理后臺可以設置評論的審核通過或拒絕。
模型設計
功能介紹前面都說了,不再贅述,直接從代碼開始講起。
這個功能新增了兩個實體類,分別是 Comment 和 AnonymousUser
評論實體類的代碼如下,可以看到除了 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 進入人工審核流程。
就是 Accept 和 Reject 這倆方法。
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 里完成一個簡單可用的評論系統。
參考資料
- element-ui 表單校驗 Rules 配置 常用黑科技 - https://www.cnblogs.com/loveyt/p/13282518.html
- http://mot.ttthyy.com/328.html
總結
以上是生活随笔為你收集整理的基于.NetCore开发博客项目 StarBlog - (30) 实现评论系统的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 出现磁盘写入错误怎么办
- 下一篇: java信息管理系统总结_java实现科