feat(comments): 添加邮箱黑名单功能

实现评论系统的邮箱黑名单功能,包括:
1. 在API中添加邮箱黑名单检查逻辑
2. 在管理后台添加邮箱黑名单设置界面
3. 支持在评论列表中直接屏蔽邮箱
4. 添加相关API接口处理邮箱黑名单操作
This commit is contained in:
anghunk
2026-01-21 10:03:00 +08:00
parent 49962db38d
commit aa5172eb84
5 changed files with 161 additions and 30 deletions

View File

@@ -44,6 +44,7 @@ export type CommentSettingsResponse = {
adminKeySet?: boolean;
requireReview?: boolean;
blockedIps?: string[];
blockedEmails?: string[];
};
export type EmailNotifySettingsResponse = {
@@ -139,6 +140,7 @@ export function saveCommentSettings(data: {
adminKey?: string;
requireReview?: boolean;
blockedIps?: string[];
blockedEmails?: string[];
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/comments', data);
}
@@ -147,6 +149,10 @@ export function blockIp(ip: string): Promise<{ message: string }> {
return post<{ message: string }>('/admin/comments/block-ip', { ip });
}
export function blockEmail(email: string): Promise<{ message: string }> {
return post<{ message: string }>('/admin/comments/block-email', { email });
}
export function exportComments(): Promise<any[]> {
return get<any[]>('/admin/comments/export');
}

View File

@@ -36,7 +36,15 @@
/>
<div class="cell-author-main">
<div class="cell-author-name">{{ item.name }}</div>
<div class="cell-author-email">{{ item.email }}</div>
<div class="cell-author-email">
<span
class="cell-email-text"
@click="handleBlockEmail(item)"
title="屏蔽该邮箱"
>
{{ item.email }}
</span>
</div>
<span class="cell-time">{{ formatDate(item.created) }}</span>
<div v-if="item.ipAddress" class="cell-author-ip">
<span class="cell-ip-text" @click="handleBlockIp(item)" title="屏蔽该 IP">{{ item.ipAddress }}</span>
@@ -180,6 +188,7 @@ import {
deleteComment,
updateCommentStatus,
blockIp,
blockEmail,
} from "../api/admin";
const comments = ref<CommentItem[]>([]);
@@ -311,6 +320,21 @@ async function handleBlockIp(item: CommentItem) {
}
}
async function handleBlockEmail(item: CommentItem) {
if (!item.email) {
return;
}
if (!window.confirm(`确认将邮箱 ${item.email} 加入黑名单吗?`)) {
return;
}
try {
const res = await blockEmail(item.email);
window.alert(res.message || "已加入邮箱黑名单");
} catch (e: any) {
error.value = e.message || "屏蔽邮箱失败";
}
}
onMounted(() => {
loadComments();
});
@@ -479,6 +503,14 @@ onMounted(() => {
margin-bottom: 2px;
}
.cell-email-text {
cursor: pointer;
}
.cell-email-text:hover {
text-decoration: underline;
}
.cell-content-text {
font-size: 13px;
line-height: 1.5;

View File

@@ -39,27 +39,9 @@
<input v-model="avatarPrefix" class="form-input" type="text" />
</div>
<h3 class="card-title">安全设置</h3>
<div class="form-item">
<label class="form-label">允许调用的域名多个域名用逗号分隔留空则不限制设置后仅匹配域名可调用前台评论组件</label>
<textarea
v-model="allowedDomains"
class="form-input"
rows="3"
placeholder="例如: example.com, test.com"
></textarea>
</div>
<div class="form-item">
<label class="form-label">IP 黑名单多个 IP 用逗号或换行分隔留空则不限制</label>
<textarea
v-model="blockedIps"
class="form-input"
rows="3"
placeholder="例如: 1.1.1.1, 2.2.2.2"
></textarea>
</div>
<div class="form-item">
<label class="form-label">管理员评论密钥</label>
<div class="form-hint" style="margin-bottom: 4px;">
<div class="form-hint" style="margin-bottom: 4px">
设置后前台使用管理员邮箱评论需输入此密钥
</div>
<input
@@ -69,6 +51,40 @@
autocomplete="new-password"
/>
</div>
<div class="form-item">
<label class="form-label"
>允许调用的域名多个域名用逗号分隔留空则不限制设置后仅匹配域名可调用前台评论组件</label
>
<textarea
v-model="allowedDomains"
class="form-input"
rows="3"
placeholder="例如: example.com, test.com"
></textarea>
</div>
<div class="form-item">
<label class="form-label"
>IP 黑名单多个 IP 用逗号或换行分隔留空则不限制</label
>
<textarea
v-model="blockedIps"
class="form-input"
rows="3"
placeholder="例如: 1.1.1.1, 2.2.2.2"
></textarea>
</div>
<div class="form-item">
<label class="form-label"
>邮箱黑名单多个邮箱用逗号或换行分隔留空则不限制</label
>
<textarea
v-model="blockedEmails"
class="form-input"
rows="3"
placeholder="例如: spam@example.com, bot@test.com"
></textarea>
</div>
<div class="card-actions">
<button class="card-button" :disabled="savingComment" @click="saveComment">
<span v-if="savingComment">保存中...</span>
@@ -205,7 +221,7 @@
</button>
<button
class="card-button secondary"
style="margin-left: auto;"
style="margin-left: auto"
@click="resetTemplatesToDefault"
>
恢复默认模板
@@ -315,6 +331,7 @@ const avatarPrefix = ref("");
const commentAdminEnabled = ref(false);
const allowedDomains = ref("");
const blockedIps = ref("");
const blockedEmails = ref("");
const commentAdminKey = ref("");
const adminKeySet = ref(false);
const requireReview = ref(false);
@@ -375,8 +392,9 @@ async function load() {
allowedDomains.value = commentRes.allowedDomains
? commentRes.allowedDomains.join(", ")
: "";
blockedIps.value = commentRes.blockedIps
? commentRes.blockedIps.join(", ")
blockedIps.value = commentRes.blockedIps ? commentRes.blockedIps.join(", ") : "";
blockedEmails.value = commentRes.blockedEmails
? commentRes.blockedEmails.join(", ")
: "";
commentAdminKey.value = commentRes.adminKey || "";
adminKeySet.value = !!commentRes.adminKeySet;
@@ -384,10 +402,8 @@ async function load() {
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
if (emailNotifyRes.templates) {
templateAdmin.value =
emailNotifyRes.templates.admin || DEFAULT_ADMIN_TEMPLATE;
templateReply.value =
emailNotifyRes.templates.reply || DEFAULT_REPLY_TEMPLATE;
templateAdmin.value = emailNotifyRes.templates.admin || DEFAULT_ADMIN_TEMPLATE;
templateReply.value = emailNotifyRes.templates.reply || DEFAULT_REPLY_TEMPLATE;
} else {
templateAdmin.value = DEFAULT_ADMIN_TEMPLATE;
templateReply.value = DEFAULT_REPLY_TEMPLATE;
@@ -515,6 +531,10 @@ async function saveComment() {
.split(/[,\n]/)
.map((d) => d.trim())
.filter(Boolean),
blockedEmails: blockedEmails.value
.split(/[,\n]/)
.map((d) => d.trim())
.filter(Boolean),
});
showToast(res.message || "保存成功", "success");

View File

@@ -63,6 +63,17 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
return c.json({ message: '当前 IP 已被限制评论,请联系站长进行处理' }, 403);
}
const blockedEmailsRow = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('comment_blocked_emails')
.first<{ value: string }>();
const blockedEmailsValue = blockedEmailsRow?.value || '';
const blockedEmails = blockedEmailsValue
? blockedEmailsValue.split(',').map((d) => d.trim()).filter(Boolean)
: [];
if (blockedEmails.length && blockedEmails.includes(email)) {
return c.json({ message: '当前邮箱已被限制评论,请联系站长进行处理' }, 403);
}
let isAdminComment = false;
if (adminEmail && email === adminEmail) {

View File

@@ -33,6 +33,7 @@ const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains';
const COMMENT_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash';
const COMMENT_REQUIRE_REVIEW_KEY = 'comment_require_review';
const COMMENT_BLOCKED_IPS_KEY = 'comment_blocked_ips';
const COMMENT_BLOCKED_EMAILS_KEY = 'comment_blocked_emails';
async function loadCommentSettings(env: Bindings) {
@@ -47,10 +48,11 @@ async function loadCommentSettings(env: Bindings) {
COMMENT_ALLOWED_DOMAINS_KEY,
COMMENT_ADMIN_KEY_HASH_KEY,
COMMENT_REQUIRE_REVIEW_KEY,
COMMENT_BLOCKED_IPS_KEY
COMMENT_BLOCKED_IPS_KEY,
COMMENT_BLOCKED_EMAILS_KEY
];
const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?)'
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
@@ -73,6 +75,11 @@ async function loadCommentSettings(env: Bindings) {
? blockedIpsRaw.split(',').map((d) => d.trim()).filter(Boolean)
: [];
const blockedEmailsRaw = map.get(COMMENT_BLOCKED_EMAILS_KEY) ?? '';
const blockedEmails = blockedEmailsRaw
? blockedEmailsRaw.split(',').map((d) => d.trim()).filter(Boolean)
: [];
// 解析允许的域名列表
const allowedDomainsRaw = map.get(COMMENT_ALLOWED_DOMAINS_KEY) ?? '';
const allowedDomains = allowedDomainsRaw
@@ -87,6 +94,7 @@ async function loadCommentSettings(env: Bindings) {
allowedDomains,
requireReview,
blockedIps,
blockedEmails,
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
};
@@ -103,6 +111,7 @@ async function saveCommentSettings(
adminKey?: string;
requireReview?: boolean;
blockedIps?: string[];
blockedEmails?: string[];
}
) {
await env.CWD_DB.prepare(
@@ -147,6 +156,10 @@ async function saveCommentSettings(
{
key: COMMENT_BLOCKED_IPS_KEY,
value: settings.blockedIps ? settings.blockedIps.join(',') : undefined
},
{
key: COMMENT_BLOCKED_EMAILS_KEY,
value: settings.blockedEmails ? settings.blockedEmails.join(',') : undefined
}
];
@@ -206,6 +219,7 @@ app.get('/api/config/comments', async (c) => {
adminKey,
adminKeySet,
blockedIps,
blockedEmails,
...publicSettings
} = settings as any;
@@ -272,6 +286,7 @@ app.put('/admin/settings/comments', async (c) => {
const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined;
const rawRequireReview = body.requireReview;
const rawBlockedIps = Array.isArray(body.blockedIps) ? body.blockedIps : [];
const rawBlockedEmails = Array.isArray(body.blockedEmails) ? body.blockedEmails : [];
const adminEmail = rawAdminEmail.trim();
const adminBadge = rawAdminBadge.trim();
@@ -291,6 +306,9 @@ app.put('/admin/settings/comments', async (c) => {
const blockedIps = rawBlockedIps
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
.filter(Boolean);
const blockedEmails = rawBlockedEmails
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
.filter(Boolean);
if (adminEmail && !isValidEmail(adminEmail)) {
return c.json({ message: '邮箱格式不正确' }, 400);
@@ -304,7 +322,8 @@ app.put('/admin/settings/comments', async (c) => {
allowedDomains,
adminKey,
requireReview,
blockedIps
blockedIps,
blockedEmails
});
return c.json({ message: '保存成功' });
@@ -352,4 +371,47 @@ app.post('/admin/comments/block-ip', async (c) => {
}
});
app.post('/admin/comments/block-email', async (c) => {
try {
const body = await c.req.json();
const rawEmail = typeof body.email === 'string' ? body.email : '';
const email = rawEmail.trim();
if (!email) {
return c.json({ message: '邮箱不能为空' }, 400);
}
if (!isValidEmail(email)) {
return c.json({ message: '邮箱格式不正确' }, 400);
}
await c.env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const row = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind(COMMENT_BLOCKED_EMAILS_KEY)
.first<{ value: string }>();
const existing = row?.value || '';
const list = existing
? existing.split(',').map((d) => d.trim()).filter(Boolean)
: [];
if (!list.includes(email)) {
list.push(email);
const joined = list.join(',');
await c.env.CWD_DB.prepare(
'REPLACE INTO Settings (key, value) VALUES (?, ?)'
)
.bind(COMMENT_BLOCKED_EMAILS_KEY, joined)
.run();
}
return c.json({ message: '已加入邮箱黑名单' });
} catch (e: any) {
return c.json({ message: e.message || '操作失败' }, 500);
}
});
export default app;