成为全栈·Node 后端篇·评论内容安全:敏感词过滤、三态审核与级联删除
评论区是社区产品最热闹的地方,也是最容易出事的地方。广告引流、辱骂、垃圾外链全靠它生长------你不处理,用户先被赶跑;你一刀切全删,社区就没了生气。难的是既要留得下,又要控得住。

这一篇对照真实的 src/services/comment.ts,讲清评论安全的三件套:敏感词过滤(命中转等长星号、违规比率定状态)、三态审核(approved / reviewing / rejected)、删除时子回复怎么级联,顺带说清为什么过滤必须放在服务端。
一、评论安全要解决什么
评论接口一旦公开,面对的是"不受控的输入"。至少要挡住三类问题:
- 垃圾广告 / 引流:"代开发票""加微信 xxx"这类。
- 辱骂 / 脏话:破坏社区氛围。
- 未审核内容外泄:草稿文章下的评论、被拒的评论,不该让公众看到。
我们的策略分层:发表时自动过滤 + 自动定态 (不依赖人工实时盯),存疑的交给三态审核流 由编辑人工裁定,最后公开列表只吐已通过的。下面逐层看真实代码。
二、P-45:敏感词过滤,命中转星号 + 比率定态
comment.ts 的 moderateContent 是过滤核心:

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/rejected,reviewing仅能由PATCH /comments/{id}/status(editor/admin)人工置位。
也就是说,用户一发表,系统立刻用 moderateContent 给出 approved 或 rejected,绝不自动进入 reviewing 。reviewing(待复核)是一个专门留给人工的"悬置态"------比如某条评论自动过了,但编辑觉得有争议想先挂起;或自动拒了,编辑想再看看。它存在的意义是:让人工拥有"既不通过也不拒绝、先放一放"的中间选项,而不是非黑即白。这是审核系统里很实用的一态,比只有"过/拒"更从容。
四、发表路径:登录、限已发布、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存的是星号版,status是moderateContent给的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=0 → approved → 进入 comments 表 → 公开列表 status='approved' 查到 → 读者可见。全程无人介入。
场景 B|垃圾广告 。"代开发票加微信 138xxxx" → 命中"代开发票"(4 字),原文短,ratio 远超 0.3 → rejected → 存库时 content 已是星号版。公众列表查不到(只吐 approved);但编辑在后台 listAdminComments(status:'rejected') 能看到,若判断为误杀,可 PATCH /comments/:id/status 改回 approved,rejectedReason 清空,评论随即出现在公开列表。拒评不是"永久消失",而是"先隔离、待人裁定"。
场景 C|擦边但正常 。"这文章有点像广告软文,但实测确实有用" → 命中"广告"2 字,但占比远低于 0.3 → approved,且存储内容变成"这文章有点像**软文,但实测确实有用" → 公众可见,脏词被遮成星号,语义不丢。
这引出一个铁律:敏感词过滤必须跑在服务端,绝不可只在前端做 。原因和阅读量去重一样------前端的一切都可被绕过。用户拿 Postman / curl 直接发原始脏话,前端过滤器形同虚设;即便前端也过滤了,恶意者照样能发。所以 moderateContent 在服务端 createComment 里执行,且写库的就是星号版------前端拿到的响应里,脏词已经不存在了,想 reconstruct 都 reconstruct 不回来。服务端才是内容安全的唯一真相源。
十一、小结
评论内容安全是分层防御,而不是单一开关:
- P-45 敏感词过滤 :
moderateContent等长星号替换 +REJECT_RATIO=0.3比率定态;escapeRegExp防正则破坏;演示词库诚实定位。 - P-46 三态 :
approved/rejected/reviewing;自动流只产前两者,reviewing仅人工PATCH置位。 - 发表闸门 :登录才能发、未发布文章 404 不可评、
parentId同文章校验、过滤后才入库。 - P-47 公开只吐 approved :无论身份,列表恒带
status='approved';未发布文章匿名 404。 - 人工复核 :
moderateComment通过即清空rejectedReason,拒则记因。 - P-56 级联删子回复 (
x-cascade: children):与分类x-cascade:none相反,因评论子回复无独立存在意义。 - 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
