为评论区添加内容过滤与安全防护
在前文《从零到一:为 Moongate 博客打造一个支持多级引用的评论区》中,我们构建了一个功能完备的评论系统。本文将在此基础上,为评论区增加内容过滤、安全校验和防滥用机制,确保评论区的健康与安全。
1. 背景与需求
在开放的评论区中,可能面临以下风险:
- 敏感词:用户可能发布违规内容,影响社区氛围。
- 恶意灌水:大量重复评论或垃圾广告。
- 跨文档污染:通过伪造
permalink将评论插入到不存在的文档或他人文章。 - XSS 攻击:通过 Markdown 注入恶意脚本(已在前文中通过安全渲染解决)。
为了维护评论区秩序,我们需要增加以下防护:
- 前端实时敏感词提示:提升用户体验,减少无效提交。
- 后端严格过滤:作为最后防线,确保入库内容安全。
- 文档归属验证:确保评论只属于当前文档。
- 防重复提交与限流:防止恶意刷屏。
2. 整体架构
过滤功能涉及前后端协作:
用户输入 → 前端实时检测(可选) → 提交 → 后端校验(敏感词、长度、文档归属) → 入库 → 返回结果
本文将分模块实现。
3. 敏感词过滤
3.1 词库设计
敏感词库应仅包含底线词汇,避免过度拦截技术术语。同时引入技术白名单,允许某些专业词汇(如“暴力破解”)正常使用。
// utils/commentValidator.ts
// 敏感词列表(仅底线词汇)
const blockedKeywords = [
"广告",
"垃圾",
"诈骗",
"赌博",
"色情",
"暴力",
"fuck",
"shit",
"damn",
];
// 技术白名单(豁免词汇,需与敏感词冲突时使用)
const technicalWhitelist = [
"暴力破解",
"暴力枚举",
"攻击向量",
"死锁",
"死循环",
"垃圾回收",
"垃圾收集",
];
3.2 验证函数实现
为避免将白名单词汇误判为敏感词,采用先移除白名单内容,再检测敏感词的策略,而非简单的子串匹配。
// utils/commentValidator.ts
// 构建敏感词正则(大小写不敏感,自动转义)
const sensitiveRegex = new RegExp(
blockedKeywords
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|"),
"i",
);
export interface ValidationResult {
valid: boolean;
message?: string;
foundWords?: string[];
}
/**
* 验证评论内容
* @param content 用户输入的文本
* @param maxLength 最大长度,默认 5000(考虑代码块)
*/
export function validateComment(
content: string,
maxLength: number = 5000,
): ValidationResult {
// 1. 空内容检查
if (!content?.trim()) {
return { valid: false, message: "评论内容不能为空" };
}
// 2. 长度限制
if (content.length > maxLength) {
return { valid: false, message: `评论内容不能超过 ${maxLength} 个字符` };
}
// 3. 移除白名单词汇,避免误判
let text = content;
for (const word of technicalWhitelist) {
text = text.replace(new RegExp(word, "gi"), "");
}
// 4. 敏感词检测
const matches = text.match(sensitiveRegex);
if (matches) {
const found = [...new Set(matches)];
return {
valid: false,
message: `包含敏感词: ${found.join(", ")}`,
foundWords: found,
};
}
return { valid: true };
}
说明:
- 通过先剔除白名单词汇,确保“垃圾回收”不会被误判为“垃圾”。
- 长度默认 5000 字符,足以容纳中等长度的代码块和技术讨论(一段 30 行的代码约 2400 字符)。
- 正则表达式一次匹配所有敏感词,性能优于循环
includes。
3.3 前端实时验证与提交按钮联动
3.3.1 修改输入预览组件
在 components/docs/CommentInputPreview.vue 中增加实时验证和字符计数。
<template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 mb-6">
<!-- 左侧预览 -->
<div class="bg-ui-bg-elevated">
<div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
// {{ t("comment.input.preview") }}
</div>
<DocsMarkdownRenderer
class="text-ui-text/90 text-base leading-relaxed"
:content="localValue"
/>
</div>
<!-- 右侧输入 -->
<div class="bg-ui-bg">
<div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
// {{ t("comment.input.input") }}
</div>
<UTextarea
:model-value="localValue"
autoresize
:rows="5"
variant="none"
:placeholder="t('comment.input.placeholder')"
class="w-full bg-transparent border-0 focus:ring-0 p-0 text-ui-text placeholder:text-ui-text-muted/50 font-mono text-sm"
@update:model-value="handleInput"
/>
<div v-if="validationError" class="mt-2 text-xs font-mono text-ui-error">
// {{ validationError }}
</div>
<div class="text-xs text-ui-text-muted text-right mt-1">
{{ localValue.length }}/{{ maxLength }}
</div>
</div>
</div>
</template>
<script setup>
import { useDebounceFn } from "@vueuse/core";
import { validateComment } from "~/utils/commentValidator";
const props = defineProps({
modelValue: { type: String, default: "" },
debounceTime: { type: Number, default: 300 },
permalink: { type: String, required: true },
storageType: { type: String, default: "none" },
maxLength: { type: Number, default: 5000 }
});
const emit = defineEmits(["update:modelValue"]);
const localValue = ref(props.modelValue);
const validationError = ref('');
const validate = (value: string) => {
const result = validateComment(value, props.maxLength);
validationError.value = result.valid ? '' : result.message;
return result.valid;
};
const handleInput = (value: string) => {
localValue.value = value;
validate(value); // 实时验证,仅用于显示错误提示
debouncedEmit(value);
};
const debouncedEmit = useDebounceFn((value: string) => {
emit("update:modelValue", value);
}, props.debounceTime);
watch(() => props.modelValue, (newVal) => {
localValue.value = newVal;
validate(newVal);
});
onMounted(() => validate(localValue.value));
</script>
3.3.2 在 Store 中管理验证状态
为了让提交按钮能够响应验证结果,需要在评论 store 中增加计算属性:
// stores/comment.ts
import { validateComment } from "~/utils/commentValidator";
export const useCommentStore = defineStore("comment", () => {
const comment = ref("");
// ... 其他状态
const isCommentValid = computed(() => {
const { valid } = validateComment(comment.value, 5000);
return valid;
});
return {
comment,
isCommentValid,
// ... 其他
};
});
3.3.3 修改评论区容器组件
在 CommentSection.vue 中,使用 store 的计算属性禁用提交按钮,并显示后端错误。
<template>
<!-- 其他部分... -->
<div class="flex justify-end mb-8">
<ClientOnly v-if="loggedIn">
<UButton
:disabled="
!commentStore.comment.trim() ||
commentStore.submitting ||
!commentStore.isCommentValid
"
:loading="commentStore.submitting"
:label="t('comment.actions.send')"
size="lg"
@click="commentStore.submitComment()"
/>
</ClientOnly>
<div v-else class="flex items-center gap-2">
<p>{{ t("comment.status.login_to_comment") }}</p>
<SharedLogin />
</div>
</div>
<!-- 显示后端错误 -->
<div v-if="commentStore.error" class="text-ui-error text-sm mt-2">
{{ commentStore.error }}
</div>
<!-- 其他部分... -->
</template>
3.4 后端严格验证
在评论和回复的 API 中,必须再次调用 validateComment,确保任何绕过前端的请求都被拦截。所有 API 统一返回对象格式(不使用 throw createError),以便前端统一处理。
修改 server/api/comment/post.ts:
import { eq } from "drizzle-orm";
import { useDB } from "~~/server/db";
import { comments, users } from "~~/server/db/schema";
import { validateComment } from "~/../utils/commentValidator";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const session = await getUserSession(event);
// 1. 验证 session
if (!session.user?.id) {
return { success: false, status: 401, message: "请先登录" };
}
// 2. 查询用户是否存在
const db = useDB();
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) {
await clearUserSession(event);
return { success: false, status: 401, message: "用户不存在" };
}
// 3. 验证评论内容
const content = body.content?.trim();
const { valid, message } = validateComment(content, 5000);
if (!valid) {
return { success: false, status: 400, message: message || "评论内容无效" };
}
// 4. 验证 permalink 非空
if (!body.permalink) {
return { success: false, status: 400, message: "永久链接不能为空" };
}
// 5. 验证文档是否存在(假设使用 Nuxt Content,具体实现需根据项目调整)
// 注意:这里使用了 `#content/server` 虚拟模块,实际项目中可能需要替换为其他方式。
// 若无法验证,应返回错误而不是放行。
try {
const { queryCollection } = await import("#content/server");
const doc = await queryCollection("docs")
.where("permalink", "=", body.permalink)
.first();
if (!doc) {
return { success: false, status: 404, message: "文档不存在" };
}
} catch (err) {
console.error("文档存在性验证失败,请检查 Nuxt Content 服务端配置", err);
return {
success: false,
status: 500,
message: "服务器配置错误,无法验证文档",
};
}
// 6. 保存评论到数据库
try {
const [comment] = await db
.insert(comments)
.values({
user_id: user.id,
content: body.content.trim(),
permalink: body.permalink,
})
.returning();
return {
success: true,
status: 201,
message: "评论存储成功",
data: { ...comment },
};
} catch (error) {
console.error("评论存储失败", error);
return { success: false, status: 500, message: "评论存储失败" };
}
});
同样修改 server/api/reply/post.ts(完整代码见第 4 节)。
4. 文档归属验证
4.1 评论 API 验证文档存在
已在评论 API 中实现(见 3.4 节)。注意:文档存在性验证依赖于 Nuxt Content 的服务端能力,若不可用,建议在数据库中维护 documents 表,并在评论表中关联文档 ID。
4.2 回复 API 完整代码(含归属验证)
由于回复表没有 permalink 字段,需要通过目标评论的 permalink 来验证。下面给出完整的 reply/post.ts 实现,包含用户认证、参数校验、敏感词过滤、归属验证和限流(可选)。
import { eq, sql } from "drizzle-orm";
import { useDB } from "~~/server/db";
import { replies, users, comments } from "~~/server/db/schema";
import { validateComment } from "~/../utils/commentValidator";
// 注意:内存限流仅用于演示,生产环境请替换为 Redis 或数据库
const rateLimit = new Map();
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const session = await getUserSession(event);
// 1. 参数校验
if (
!body.target_id ||
!["comment", "reply"].includes(body.target_type) ||
!body.content?.trim()
) {
return { success: false, status: 400, message: "参数错误" };
}
if (!body.permalink) {
return { success: false, status: 400, message: "缺少 permalink 参数" };
}
// 确保 target_id 为数字
const targetId = Number(body.target_id);
if (isNaN(targetId)) {
return { success: false, status: 400, message: "target_id 必须为数字" };
}
// 2. 验证 session
if (!session.user?.id) {
return { success: false, status: 401, message: "请先登录" };
}
const db = useDB();
// 3. 验证用户存在
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) {
await clearUserSession(event);
return { success: false, status: 401, message: "用户不存在" };
}
// 4. 验证回复内容
const content = body.content?.trim();
const { valid, message } = validateComment(content, 5000);
if (!valid) {
return {
success: false,
status: 400,
message: message || "回复包含敏感词",
};
}
// 5. 验证目标存在并检查是否属于当前文档
let targetPermalink = "";
if (body.target_type === "comment") {
const comment = await db.query.comments.findFirst({
where: eq(comments.id, targetId),
});
if (!comment) {
return { success: false, status: 404, message: "评论不存在" };
}
targetPermalink = comment.permalink;
} else {
// 目标是回复,需要向上追溯找到根评论的 permalink
// 使用递归 CTE 查询(PostgreSQL)
const result = await db.execute(sql`
WITH RECURSIVE reply_chain AS (
SELECT id, target_id, target_type
FROM replies
WHERE id = ${targetId}
UNION ALL
SELECT r.id, r.target_id, r.target_type
FROM replies r
JOIN reply_chain rc ON rc.target_id = r.id AND rc.target_type = 'reply'
)
SELECT c.permalink
FROM reply_chain rc
JOIN comments c ON c.id = rc.target_id AND rc.target_type = 'comment'
LIMIT 1
`);
if (!result.rows.length) {
return { success: false, status: 404, message: "目标评论或回复不存在" };
}
targetPermalink = result.rows[0].permalink;
}
if (targetPermalink !== body.permalink) {
return { success: false, status: 400, message: "目标不属于当前文档" };
}
// 6. 可选:防重复提交限流(同一用户对同一文档 1 分钟内只能回复一次)
// 注意:内存限流仅用于演示,生产环境请使用 Redis 或数据库
const rateKey = `${user.id}:${body.permalink}`;
const last = rateLimit.get(rateKey);
if (last && Date.now() - last < 60000) {
return { success: false, status: 429, message: "操作过于频繁,请稍后再试" };
}
rateLimit.set(rateKey, Date.now());
setTimeout(() => rateLimit.delete(rateKey), 60000); // 自动清理过期条目
// 7. 保存回复
try {
const [newReply] = await db
.insert(replies)
.values({
user_id: user.id,
target_id: targetId,
target_type: body.target_type,
content: content,
})
.returning();
return {
success: true,
status: 201,
message: "回复成功",
data: newReply,
};
} catch (error) {
console.error(error);
return { success: false, status: 500, message: "服务器内部错误" };
}
});
说明:
- 递归 CTE 查询支持多级引用(回复的回复),并获取根评论的
permalink。 - 若不需要多级引用,可以限制
target_type只能为'comment',简化归属验证。 - 限流使用内存 Map,生产环境请替换为 Redis 或数据库实现,以保证多实例同步。
- 增加了
target_id类型校验,确保为有效数字。
5. 防重复提交与限流
5.1 前端禁用按钮
已在 CommentSection.vue 中实现,通过 submitting 状态禁用提交按钮。
5.2 后端限流
已在回复 API 中添加简单内存限流示例(每分钟 1 次)。生产环境请务必替换为 Redis 或数据库,避免内存丢失或跨实例不同步。
6. 多语言翻译
新增的文案需要添加到多语言文件中。以下提供中、英、日三语示例,可根据实际路径调整。
/i18n/locales/zh_cn.json
{
"comment": {
"input": {
"preview": "预览",
"input": "输入",
"placeholder": "支持 Markdown,代码块请用 ``` 包裹..."
}
}
}
/i18n/locales/en.json
{
"comment": {
"input": {
"preview": "Preview",
"input": "Input",
"placeholder": "Markdown supported, use ``` for code blocks..."
}
}
}
/i18n/locales/ja.json
{
"comment": {
"input": {
"preview": "プレビュー",
"input": "入力",
"placeholder": "Markdown 対応、コードブロックは ``` で囲んでください..."
}
}
}
7. 整合与测试
7.1 文件清单
utils/commentValidator.ts– 敏感词验证逻辑(含白名单剔除)components/docs/CommentInputPreview.vue– 前端实时验证、字符计数stores/comment.ts– 添加isCommentValid计算属性和error状态server/api/comment/post.ts– 后端验证 + 文档归属server/api/reply/post.ts– 后端验证 + 归属验证 + 限流(含类型校验)
7.2 测试用例
- 空内容 → 提示“评论内容不能为空”
- 超长内容(>5000 字符)→ 提示“不能超过 5000 个字符”
- 包含敏感词(如“垃圾”)→ 前端提示,提交按钮禁用;若绕过前端,后端返回错误
- 包含白名单词(如“暴力破解”)→ 正常提交
- 伪造
permalink→ 后端返回“文档不存在” - 快速多次提交 → 触发限流提示(若实现)
- 回复多级引用 → 递归查询正确找到根评论归属
8. 总结与展望
通过添加内容过滤、归属验证和限流机制,评论区的安全性大大提升,能够抵御常见的恶意行为。这些功能与已有的 Markdown 安全渲染、用户认证共同构成了一个健壮的评论系统。
未来还可扩展:
- 管理员审核模式:敏感词评论自动进入待审列表。
- 黑名单机制:封禁恶意用户。
- 评论举报功能:让读者参与监督。
现在,评论区已经可以放心地开放给所有读者了。如果在实践中有任何问题,欢迎在评论区交流。
如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️

