日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 运维知识 > windows >内容正文

windows

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

發布時間:2023/12/24 windows 29 coder
生活随笔 收集整理的這篇文章主要介紹了 基于.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<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 里完成一個簡單可用的評論系統。

參考資料

  • element-ui 表單校驗 Rules 配置 常用黑科技 - https://www.cnblogs.com/loveyt/p/13282518.html
  • http://mot.ttthyy.com/328.html

總結

以上是生活随笔為你收集整理的基于.NetCore开发博客项目 StarBlog - (30) 实现评论系统的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。