成为全栈·Node 后端篇·评论内容安全:敏感词过滤、三态审核与级联删除

成为全栈·Node 后端篇·评论内容安全:敏感词过滤、三态审核与级联删除

评论区是社区产品最热闹的地方,也是最容易出事的地方。广告引流、辱骂、垃圾外链全靠它生长------你不处理,用户先被赶跑;你一刀切全删,社区就没了生气。难的是既要留得下,又要控得住

这一篇对照真实的 src/services/comment.ts,讲清评论安全的三件套:敏感词过滤(命中转等长星号、违规比率定状态)、三态审核(approved / reviewing / rejected)、删除时子回复怎么级联,顺带说清为什么过滤必须放在服务端。

一、评论安全要解决什么

评论接口一旦公开,面对的是"不受控的输入"。至少要挡住三类问题:

  1. 垃圾广告 / 引流:"代开发票""加微信 xxx"这类。
  2. 辱骂 / 脏话:破坏社区氛围。
  3. 未审核内容外泄:草稿文章下的评论、被拒的评论,不该让公众看到。

我们的策略分层:发表时自动过滤 + 自动定态 (不依赖人工实时盯),存疑的交给三态审核流 由编辑人工裁定,最后公开列表只吐已通过的。下面逐层看真实代码。

二、P-45:敏感词过滤,命中转星号 + 比率定态

comment.tsmoderateContent 是过滤核心:

ts 复制代码
// src/services/comment.ts --- moderateContent
/** 基础敏感词库(演示用,非完整词库)。 */
export const SENSITIVE_WORDS: readonly string[] = [
  '广告',
  'spam',
  'fuck',
  'shit',
  '垃圾',
  '代开发票',
];

/** 违规比率阈值:超过则判定 rejected。 */
export const REJECT_RATIO = 0.3;

/** 正则转义,避免词库中特殊字符破坏正则。 */
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

export const moderateContent = (raw: string): ModerationResult => {
  let content = raw;
  let hitChars = 0;
  for (const word of SENSITIVE_WORDS) {
    if (!word) continue;
    const re = new RegExp(escapeRegExp(word), 'gi');
    content = content.replace(re, (m) => '*'.repeat(m.length));
    const matches = raw.match(re);
    if (matches) hitChars += matches.reduce((acc, m) => acc + m.length, 0);
  }
  const ratio = raw.length === 0 ? 0 : hitChars / raw.length;
  const status: CommentStatus = ratio > REJECT_RATIO ? 'rejected' : 'approved';
  return { content, status };
};

几个设计点:

  • 等长星号替换*'.repeat(m.length)------"广告"变"**"、"fuck"变"****",长度不变,既不暴露原词,排版也不塌。存库的 content 已经是替换后的展示文本(schema 注释明确:"content 存已做敏感词转星号处理后的展示文本")。
  • 比率定态,而非"命中即拒"ratio = 命中字符数 / 原文长度,超过 REJECT_RATIO(0.3) 才 rejected,否则 approved。这避免"一句正常话里恰好出现'广告'两个字(比如'这文章像广告但其实是测评')就被整条拒掉"------只有脏话占比高到一定程度才判违规,更贴近真实语义。
  • escapeRegExp 防正则破坏 :词库里若含 . * ( 等特殊字符,直接拼进 new RegExp 会变元字符出错,先转义再建正则,是写过滤器的必备基本功。
  • 诚实定位:注释写清"基础演示词库,不追求完整"。真正生产要接专业内容安全服务(如阿里云/腾讯云内容安全 API)或本地 AC 自动机词库,这里用一个小词库把"机制"讲清楚即可,不假装能拦住所有违规。

三、P-46:三态审核流,reviewing 是人工兜底态

评论状态是三态,定义在 comment.ts

ts 复制代码
// src/services/comment.ts
export type CommentStatus = 'approved' | 'rejected' | 'reviewing';

关键在于自动流和人工流的分工(注释原话):

自动流(发表)只产出 approved / rejectedreviewing 仅能由 PATCH /comments/{id}/status(editor/admin)人工置位。

也就是说,用户一发表,系统立刻用 moderateContent 给出 approvedrejected绝不自动进入 reviewingreviewing(待复核)是一个专门留给人工的"悬置态"------比如某条评论自动过了,但编辑觉得有争议想先挂起;或自动拒了,编辑想再看看。它存在的意义是:让人工拥有"既不通过也不拒绝、先放一放"的中间选项,而不是非黑即白。这是审核系统里很实用的一态,比只有"过/拒"更从容。

四、发表路径:登录、限已发布、parentId 校验

createComment 把发表前的检查收口:

ts 复制代码
// src/services/comment.ts --- createComment
export const createComment = async (
  userId: number,
  articleKey: string,
  input: CommentInput,
): Promise<ReturnType<typeof toComment>> => {
  const article = await resolveArticle(articleKey);
  if (article?.status !== 'published') throw new AppError(ErrCode.NOT_FOUND, 404);
  if (input.parentId != null) {
    const parent = (
      await getDb()
        .select({ id: comments.id, articleId: comments.articleId })
        .from(comments)
        .where(eq(comments.id, input.parentId))
        .limit(1)
        .all()
    )[0];
    if (!parent || parent.articleId !== article.id) throw new AppError(ErrCode.NOT_FOUND, 404);
  }
  const mod = moderateContent(input.content);
  const [row] = await getDb()
    .insert(comments)
    .values({
      articleId: article.id,
      userId,
      userName: await userNameOf(userId),
      parentId: input.parentId ?? null,
      content: mod.content,
      status: mod.status,
      createdAt: new Date(),
    })
    .returning()
    .all();
  if (!row) throw new AppError(ErrCode.INTERNAL, 500);
  return toComment(row);
};

要点:

  • 必须登录userId 是必填入参(路由层 authMiddleware 保证),匿名不能发评论------这是内容安全的第一道闸,匿名发布等于给灌水者发门票。
  • 未发布文章不可评status !== 'published' 直接 404,连"能不能评论"都不告诉对方(隐瞒存在性)。
  • 回复校验parentId 若存在,必须属于同一篇文章(防止把回复挂到别的文章下制造错乱)。
  • 过滤后才入库content 存的是星号版,statusmoderateContent 给的 approved/rejected

五、P-47:公开列表只吐 approved

这是公开可见性铁律在评论上的体现。listArticleComments

ts 复制代码
const privileged = user && (String(article.authorId) === user.id || user.role === 'admin');
if (article.status !== 'published' && !privileged) throw new AppError(ErrCode.NOT_FOUND, 404);
const conds = and(eq(comments.articleId, article.id), eq(comments.status, 'approved'));

无论你是普通读者、作者本人、还是 admin,公开列表查询条件永远带 status = 'approved' 。区别只在于:未发布文章,匿名直接 404(看不到任何评论);作者/admin 能看到文章存在,但评论列表仍然只列 approved ------被拒的、待复核的,公众和作者都不会在普通列表里撞见。被拒评论的"为什么被拒"通过 rejectedReason 走后台单独通道给编辑看,不进公开视野。

六、人工复核:PATCH /comments/:id/status

编辑在后台对可疑评论做最终裁定,落到 moderateComment

ts 复制代码
// src/services/comment.ts --- moderateComment
export const moderateComment = async (
  id: number,
  status: CommentStatus,
  reason: string | null | undefined,
): Promise<ReturnType<typeof toComment>> => {
  const existing = (
    await getDb().select().from(comments).where(eq(comments.id, id)).limit(1).all()
  )[0];
  if (!existing) throw new AppError(ErrCode.NOT_FOUND, 404);
  const rejectedReason = status === 'approved' ? null : (reason ?? existing.rejectedReason);
  const [row] = await getDb()
    .update(comments)
    .set({ status, rejectedReason })
    .where(eq(comments.id, id))
    .returning()
    .all();
  if (!row) throw new AppError(ErrCode.INTERNAL, 500);
  return toComment(row);
};

细节:status 设为 approved 时,清空 rejectedReason ------一条被恢复通过的评论,不该还挂着旧的拒因;若设为 rejected,则写入 reason(编辑填的拒因)或沿用旧拒因。这个"通过即清因"的小逻辑,保证了数据自洽,前端展示不用做"状态是 approved 却有拒因"的怪异判断。

七、P-56:删除级联子回复(x-cascade: children)

删除评论和删除分类的级联策略正好相反,值得对照:

ts 复制代码
// src/services/comment.ts --- deleteComment
export const deleteComment = async (id: number): Promise<void> => {
  const db = getDb();
  await db.delete(comments).where(eq(comments.parentId, id)).run(); // 级联删子回复
  const res = await db.delete(comments).where(eq(comments.id, id)).run();
  // 复用 run() 的 changes 判定存在性,避免与 guard 内 resolveCommentOwner 重复查库(P3-2)
  if (res.changes === 0) throw new AppError(ErrCode.NOT_FOUND, 404);
};

分类删除是 x-cascade:none------有子节点就拒删,逼你先清依赖(见分类树:无限级分类的存储、查询与环检测的 P-28)。但评论删除是 x-cascade: children ------直接把它的子回复(一层回复)一起删掉。为什么不同?因为分类是"结构性目录",误删子树代价大、且用户通常不想丢数据;而评论的子回复是"对这条评论的回复",父都没了,子回复挂空毫无意义,级联删反而更干净,避免孤儿回复。两条规则都合理,区别只在业务语义------级联与否,永远由"数据还有没有独立存在的意义"决定,不是拍脑袋。

res.changes === 0 这行也很讲究:用 delete 的 changes 判定"这条评论到底存不存在",而不是在 guard 里再查一次 owner------既做了存在性校验,又省掉一次冗余查询(注释标了 P3-2)。

八、P-27:reviewing 兜底态的设计意图

回到三态里最特殊的 reviewing。它为什么值得单独存在、且只能人工置位?设想场景:一条评论自动过了,但含有 borderline 的引战内容,编辑不想立刻放出来惹事,也不想直接拒(万一是误判伤了正常用户)。reviewing 就是给这种"先挂起、观察一下"的缓冲。它让审核系统从"二元判决"升级成"三元缓冲",在内容合规压力大的产品里几乎是标配。我们把它严格限制为人工入口,就是防止自动化流程随意把评论丢进悬置态、导致用户评论"发了却永远看不到"的糟糕体验------自动流必须给明确结论(过/拒),悬置只能由人决定。

十、一条评论的完整生命周期,以及过滤为何必须服务端

把上面所有规则串成三个具体场景,你会更清楚系统怎么运转:

场景 A|正常评论 。"这篇文章写得真好" → moderateContent 无词命中,ratio=0approved → 进入 comments 表 → 公开列表 status='approved' 查到 → 读者可见。全程无人介入。

场景 B|垃圾广告 。"代开发票加微信 138xxxx" → 命中"代开发票"(4 字),原文短,ratio 远超 0.3 → rejected → 存库时 content 已是星号版。公众列表查不到(只吐 approved);但编辑在后台 listAdminComments(status:'rejected') 能看到,若判断为误杀,可 PATCH /comments/:id/status 改回 approvedrejectedReason 清空,评论随即出现在公开列表。拒评不是"永久消失",而是"先隔离、待人裁定"。

场景 C|擦边但正常 。"这文章有点像广告软文,但实测确实有用" → 命中"广告"2 字,但占比远低于 0.3 → approved,且存储内容变成"这文章有点像**软文,但实测确实有用" → 公众可见,脏词被遮成星号,语义不丢。

这引出一个铁律:敏感词过滤必须跑在服务端,绝不可只在前端做 。原因和阅读量去重一样------前端的一切都可被绕过。用户拿 Postman / curl 直接发原始脏话,前端过滤器形同虚设;即便前端也过滤了,恶意者照样能发。所以 moderateContent 在服务端 createComment 里执行,且写库的就是星号版------前端拿到的响应里,脏词已经不存在了,想 reconstruct 都 reconstruct 不回来。服务端才是内容安全的唯一真相源。

十一、小结

评论内容安全是分层防御,而不是单一开关:

  1. P-45 敏感词过滤moderateContent 等长星号替换 + REJECT_RATIO=0.3 比率定态;escapeRegExp 防正则破坏;演示词库诚实定位。
  2. P-46 三态approved/rejected/reviewing;自动流只产前两者,reviewing 仅人工 PATCH 置位。
  3. 发表闸门 :登录才能发、未发布文章 404 不可评、parentId 同文章校验、过滤后才入库。
  4. P-47 公开只吐 approved :无论身份,列表恒带 status='approved';未发布文章匿名 404。
  5. 人工复核moderateComment 通过即清空 rejectedReason,拒则记因。
  6. P-56 级联删子回复x-cascade: children):与分类 x-cascade:none 相反,因评论子回复无独立存在意义。
  7. P-27 reviewing 兜底态:给边界争议内容"先挂起"的缓冲,且只能人工置位,避免自动流误挂。

下一篇({{LINK:M1-28}})我们聊"辅助接口":相邻文章、相关推荐、目录 TOC、面包屑、全站统计、搜索这些"锦上添花"的端点,怎么在薄路由 + service 纪律下干净地长出来。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
敲敲敲敲暴你脑袋4 小时前
地图瓦片批量改色来啦!
node.js·gis·数据可视化
太子釢1 天前
AI 开发个人记账 App(服务端篇)
node.js·ai编程
用户64340495148511 天前
Elpis 项目构建工具与前端基建实践总结
node.js
FungLeo1 天前
成为全栈·Node 后端篇·后端测试策略:单元、集成与测试数据库
单元测试·node.js·集成测试·测试策略·成为全栈·测试数据库
FungLeo2 天前
成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离
node.js·读写分离·数据去重·接口防刷·成为全栈·数据冷却
脉动数据行情12 天前
Node.js WebSocket 实现贵金属实时行情监听 伦敦金 / 伦敦银自动重连方案
websocket·node.js·vim
不老刘2 天前
一行命令解决 Node.js 版本兼容问题:`--openssl-legacy-provider` 深度解析
node.js
万敏3 天前
Vue3 全栈实战:第一阶段复盘(第1-8周)
vue.js·node.js·全栈
濮水大叔3 天前
舒服了,CabloyJS 的 AI Spec 驱动开发会自动生成甘特图和燃尽图
typescript·node.js·vibecoding