feat: 新增CWD评论系统前后端代码及文档

refactor: 移除旧版评论系统代码并重构为Vue3前端

docs: 更新后端配置文档说明

fix: 修复评论提交频率限制和邮件通知逻辑

style: 格式化代码并优化样式

test: 添加Vitest测试配置

build: 更新依赖项和构建配置

chore: 清理无用文件和缓存
This commit is contained in:
anghunk
2026-01-19 14:58:18 +08:00
parent 836ac08296
commit 3d0e3a317a
53 changed files with 1201 additions and 28231 deletions

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const deleteComment = async (c: Context<{ Bindings: Bindings }>) => {
const id = c.req.query('id');
if (!id) {
return c.json({ message: "Missing id" }, 400);
}
// 从数据库中直接删除评论
const { success } = await c.env.CWD_DB.prepare(
"DELETE FROM Comment WHERE id = ?"
).bind(id).run();
if (!success) {
return c.json({ message: "Delete operation failed" }, 500);
}
return c.json({
message: `Comment deleted, id: ${id}.`
});
};

View File

@@ -0,0 +1,18 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
try {
let email: string | null = null;
if (c.env.CWD_CONFIG_KV) {
email = await c.env.CWD_CONFIG_KV.get('settings:admin_notify_email');
}
if (!email) {
email = c.env.EMAIL_ADDRESS || null;
}
return c.json({ email });
} catch (e: any) {
return c.json({ message: e.message }, 500);
}
};

View File

@@ -0,0 +1,41 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
const page = parseInt(c.req.query('page') || '1');
const limit = 10;
const offset = (page - 1) * limit;
// 1. 获取总数
const totalCount = await c.env.CWD_DB.prepare(
"SELECT COUNT(*) as count FROM Comment"
).first<{ count: number }>();
// 2. 分页查询数据
const { results } = await c.env.CWD_DB.prepare(
`SELECT * FROM Comment ORDER BY pub_date DESC LIMIT ? OFFSET ?`
).bind(limit, offset).all();
// 3. 映射字段名以符合你的 API 规范
const data = results.map((row: any) => ({
id: row.id,
pubDate: row.pub_date,
author: row.author,
email: row.email,
postSlug: row.post_slug,
url: row.url,
ipAddress: row.ip_address,
contentText: row.content_text,
contentHtml: row.content_html,
status: row.status
}));
return c.json({
data,
pagination: {
page,
limit,
total: Math.ceil((totalCount?. count || 0) / limit)
}
});
};

View File

@@ -0,0 +1,62 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
// 简单配置:允许尝试 5 次,锁定 30 分钟
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 30 * 60; // 秒
export const adminLogin = async (c: Context<{ Bindings: Bindings }>) => {
const data = await c.req.json();
const ip = c.req.header('cf-connecting-ip') || '127.0.0.1';
const blockKey = `block:${ip}`;
const attemptKey = `attempts:${ip}`;
// 1. 检查 IP 是否被封禁
const isBlocked = await c.env.CWD_AUTH_KV.get(blockKey);
if (isBlocked) {
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
}
// 2. 验证用户名密码
const ADMIN_NAME = c.env.ADMIN_NAME || 'Admin';
const ADMIN_PASSWORD = c.env.ADMIN_PASSWORD || 'password';
const isValid = data.name === ADMIN_NAME && data.password === ADMIN_PASSWORD;
if (!isValid) {
// --- 登录失败逻辑 ---
// 获取当前失败次数
const attempts = parseInt((await c.env.CWD_AUTH_KV.get(attemptKey)) || '0') + 1;
if (attempts >= MAX_ATTEMPTS) {
// 达到上限,封禁 30 分钟
await c.env.CWD_AUTH_KV.put(blockKey, '1', { expirationTtl: LOCK_TIME });
await c.env.CWD_AUTH_KV.delete(attemptKey); // 清除尝试计数
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
} else {
// 记录失败次数,设置 10 分钟内连续失败才计数
await c.env.CWD_AUTH_KV.put(attemptKey, attempts.toString(), { expirationTtl: 600 });
return c.json({ message: 'Invalid username or password', failedAttempts: attempts }, 401);
}
}
// --- 3. 登录成功逻辑 ---
await c.env.CWD_AUTH_KV.delete(attemptKey);
// 生成 Token (你的 tempKey)
const tempKey = crypto.randomUUID();
// 将 Token 存入 KV有效期 24 小时86400秒
await c.env.CWD_AUTH_KV.put(
`token:${tempKey}`,
JSON.stringify({
user: data.name,
ip: ip,
}),
{ expirationTtl: 86400 }
);
return c.json({
data: { key: tempKey },
});
};

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export const setAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
try {
const { email } = await c.req.json();
if (!email || !isValidEmail(email)) {
return c.json({ message: '邮箱格式不正确' }, 400);
}
if (!c.env.CWD_CONFIG_KV) {
return c.json({ message: '未配置 CWD_CONFIG_KV无法保存设置' }, 500);
}
await c.env.CWD_CONFIG_KV.put('settings:admin_notify_email', email);
return c.json({ message: '保存成功' });
} catch (e: any) {
return c.json({ message: e.message }, 500);
}
};

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const updateStatus = async (c: Context<{ Bindings: Bindings }>) => {
const id = c.req.query('id');
const status = c.req.query('status'); // 按照你规范中 URL 参数的形式
if (!id || !status) {
return c.json({ message: "Missing id or status" }, 400);
}
const { success } = await c.env.CWD_DB.prepare(
"UPDATE Comment SET status = ? WHERE id = ?"
).bind(status, id).run();
if (!success) {
return c.json({ message: "Update failed" }, 500);
}
return c.json({
message: `Comment status updated, id: ${id}, status: ${status}.`
});
};

View File

@@ -0,0 +1,108 @@
import { Context } from 'hono'
import { Bindings } from '../../bindings'
import { getCravatar } from '../../utils/getAvatar'
export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
const post_slug = c.req.query('post_slug')
const page = parseInt(c.req.query('page') || '1')
const limit = Math.min(parseInt(c.req.query('limit') || '20'), 50)
const nested = c.req.query('nested') !== 'false'
const avatar_prefix = c.req.query('avatar_prefix')
const offset = (page - 1) * limit
if (!post_slug) return c.json({ message: "post_slug is required" }, 400)
try {
// 1. 查询审核通过的评论
const query = `
SELECT id, author, email, url, content_text as contentText,
content_html as contentHtml, pub_date as pubDate, parent_id as parentId,
post_slug as postSlug
FROM Comment
WHERE post_slug = ? AND status = "approved"
ORDER BY pub_date DESC
`
const { results } = await c.env.CWD_DB.prepare(query).bind(post_slug).all()
// 2. 批量处理头像并格式化
const allComments = await Promise.all(results.map(async (row: any) => ({
...row,
avatar: await getCravatar(row.email, avatar_prefix || undefined),
replies: []
})))
// 3. 处理嵌套逻辑扁平化2级往后的回复都放在根评论的 replies 中)
if (nested) {
const commentMap = new Map()
const rootComments: any[] = []
// 建立评论映射
allComments.forEach(comment => commentMap.set(comment.id, comment))
// 找出所有根评论
allComments.forEach(comment => {
if (!comment.parentId) {
rootComments.push(comment)
}
})
// 为每个非根评论找到其根评论,并添加 replyToAuthor 字段
allComments.forEach(comment => {
if (comment.parentId) {
// 获取直接父评论的作者名
const parentComment = commentMap.get(comment.parentId)
if (parentComment) {
comment.replyToAuthor = parentComment.author
}
// 向上查找根评论
let rootId = comment.parentId
let current = commentMap.get(rootId)
while (current && current.parentId) {
rootId = current.parentId
current = commentMap.get(rootId)
}
// 将回复添加到根评论的 replies 中
const rootComment = commentMap.get(rootId)
if (rootComment && !rootComment.parentId) {
rootComment.replies.push(comment)
}
}
})
// 对每个根评论的 replies 按时间正序排列
rootComments.forEach(root => {
root.replies.sort((a: any, b: any) =>
new Date(a.pubDate).getTime() - new Date(b.pubDate).getTime()
)
})
// 对根评论进行分页
const paginatedData = rootComments.slice(offset, offset + limit)
return c.json({
data: paginatedData,
pagination: {
page,
limit,
total: Math.ceil(rootComments.length / limit),
totalCount: allComments.length,
}
})
} else {
// 非嵌套逻辑直接分页
const paginatedData = allComments.slice(offset, offset + limit)
return c.json({
data: paginatedData,
pagination: {
page,
limit,
total: Math.ceil(allComments.length / limit),
totalCount: allComments.length,
}
})
}
} catch (e: any) {
return c.json({ message: e.message }, 500)
}
}

View File

@@ -0,0 +1,151 @@
import { Context } from 'hono';
import { UAParser } from 'ua-parser-js';
import { Bindings } from '../../bindings';
import { sendCommentNotification, sendCommentReplyNotification } from '../../utils/email';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
return content.replace(/<script[\s\S]*?<\/script>/g, "");
}
export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
const data = await c.req.json();
if (!data || typeof data !== 'object') {
return c.json({ message: '无效的请求体' }, 400);
}
const { post_slug, content: rawContent, author: rawAuthor, email, url, parent_id, post_title, post_url } = data;
if (!post_slug || typeof post_slug !== 'string') {
return c.json({ message: 'post_slug 必填' }, 400);
}
if (!rawContent || typeof rawContent !== 'string') {
return c.json({ message: '评论内容不能为空' }, 400);
}
if (!rawAuthor || typeof rawAuthor !== 'string') {
return c.json({ message: '昵称不能为空' }, 400);
}
if (!email || typeof email !== 'string') {
return c.json({ message: '邮箱不能为空' }, 400);
}
const userAgent = c.req.header('user-agent') || "";
// 1. 获取 IP (Worker 获取 IP 的标准方式)
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
// 2. 检查评论频率控制 (对应 canPostComment)
// 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF
const lastComment = await c.env.CWD_DB.prepare(
'SELECT pub_date FROM Comment WHERE ip_address = ? ORDER BY pub_date DESC LIMIT 1'
).bind(ip).first<{ pub_date: string }>();
if (lastComment) {
const lastTime = new Date(lastComment.pub_date).getTime();
if (Date.now() - lastTime < 10 * 1000) {
return c.json({ message: "评论频繁等10s后再试" }, 429);
}
}
// 初始化邮件日志表(若不存在)
await c.env.CWD_DB.prepare(`
CREATE TABLE IF NOT EXISTS EmailLog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient TEXT NOT NULL,
type TEXT NOT NULL,
ip_address TEXT,
created_at TEXT NOT NULL
)
`).run();
// 3. 准备数据
const content = checkContent(rawContent);
const author = checkContent(rawAuthor);
const uaParser = new UAParser(userAgent);
const uaResult = uaParser.getResult();
// 4. 写入 D1 数据库
try {
const { success } = await c.env.CWD_DB.prepare(`
INSERT INTO Comment (
pub_date, post_slug, author, email, url, ip_address,
os, browser, device, user_agent, content_text, content_html,
parent_id, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
new Date().toISOString(),
post_slug,
author,
email,
url || null,
ip,
`${uaResult.os.name || ""} ${uaResult.os.version || ""}`.trim(),
`${uaResult.browser.name || ""} ${uaResult.browser.version || ""}`.trim(),
uaResult.device.model || uaResult.device.type || "Desktop",
userAgent,
content,
content,
parent_id || null,
"approved" // 或者从环境变量读取默认状态
).run();
if (!success) throw new Error("Database insert failed");
// 5. 发送邮件通知 (后台异步执行,不阻塞用户响应)
if (c.env.SEND_EMAIL && c.env.CF_FROM_EMAIL) {
c.executionCtx.waitUntil((async () => {
try {
if (data.parent_id) {
// 回复逻辑:查询父评论信息
const parentComment = await c.env.CWD_DB.prepare(
"SELECT author, email, content_text FROM Comment WHERE id = ?"
).bind(data.parent_id).first<{ author: string, email: string, content_text: string }>();
if (parentComment && parentComment.email !== data.email) {
const recentUserMail = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1"
).bind(parentComment.email).first<{ created_at: string }>();
const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000);
if (canSendUserMail) {
await sendCommentReplyNotification(c.env, {
toEmail: parentComment.email,
toName: parentComment.author,
postTitle: data.post_title,
parentComment: parentComment.content_text,
replyAuthor: author,
replyContent: content,
postUrl: data.post_url,
});
await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run();
}
}
} else {
// 新评论通知站长
const adminEmailRow = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE type = 'admin-notify' ORDER BY created_at DESC LIMIT 1"
).first<{ created_at: string }>();
const canSendAdminMail = !adminEmailRow || (Date.now() - new Date(adminEmailRow.created_at).getTime() > 15 * 1000);
if (canSendAdminMail) {
await sendCommentNotification(c.env, {
postTitle: data.post_title,
postUrl: data.post_url,
commentAuthor: author,
commentContent: content
});
await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind('admin', 'admin-notify', ip, new Date().toISOString()).run();
}
}
} catch (mailError) {
console.error("Mail Notification Failed:", mailError);
}
})());
}
return c.json({ message: "Comment submitted. Awaiting moderation." });
} catch (e: any) {
console.error("Create Comment Error:", e);
return c.json({ message: "Internal Server Error" }, 500);
}
};

View File

@@ -0,0 +1,13 @@
export type Bindings = {
CWD_DB: D1Database
CWD_AUTH_KV: KVNamespace;
CWD_CONFIG_KV?: KVNamespace;
ALLOW_ORIGIN: string
CF_FROM_EMAIL?: string
SEND_EMAIL?: {
send: (message: any) => Promise<any>
}
EMAIL_ADDRESS?: string
ADMIN_NAME: string
ADMIN_PASSWORD: string
}

View File

@@ -0,0 +1,41 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Bindings } from './bindings';
import { customCors } from './utils/cors';
import { adminAuth } from './utils/auth';
import { getComments } from './api/public/getComments';
import { postComment } from './api/public/postComment';
import { adminLogin } from './api/admin/login';
import { deleteComment } from './api/admin/deleteComment';
import { listComments } from './api/admin/listComments';
import { updateStatus } from './api/admin/updateStatus';
import { getAdminEmail } from './api/admin/getAdminEmail';
import { setAdminEmail } from './api/admin/setAdminEmail';
const app = new Hono<{ Bindings: Bindings }>();
// 跨域
app.use('/api/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
return corsMiddleware(c, next);
});
app.use('/admin/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
return corsMiddleware(c, next);
});
// API
app.get('/api/comments', getComments);
app.post('/api/comments', postComment);
app.post('/admin/login', adminLogin);
app.use('/admin/*', adminAuth);
app.delete('/admin/comments/delete', deleteComment);
app.get('/admin/comments/list', listComments);
app.put('/admin/comments/status', updateStatus);
// 设置接口
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);
export default app;

View File

@@ -0,0 +1,23 @@
import { Context, Next } from 'hono';
import { Bindings } from '../bindings';
export const adminAuth = async (c: Context<{ Bindings: Bindings }>, next: Next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) return c.json({ message: "Unauthorized" }, 401);
const sessionData = await c.env.CWD_AUTH_KV.get(`token:${token}`);
if (!sessionData) {
return c.json({ message: "Token expired or invalid" }, 401);
}
const session = JSON.parse(sessionData);
const currentIp = c.req.header('cf-connecting-ip');
// 安全检查:如果 IP 发生变化(比如 Token 被盗),要求重新登录
// if (session.ip !== currentIp) {
// await c.env.CWD_AUTH_KV.delete(`token:${token}`);
// return c.json({ message: "Security alert: IP changed" }, 401);
// }
await next();
};

View File

@@ -0,0 +1,23 @@
import { cors } from 'hono/cors'
export const customCors = (allowOriginStr: string | undefined) => {
// 1. 将环境变量字符串解析为数组
// 如果环境变量不存在,则默认为空数组
const allowedOrigins = allowOriginStr
? allowOriginStr.split(',').map(origin => origin.trim())
: []
return cors({
origin: (origin) => {
// 如果请求的 origin 在白名单中,或者是本地文件(null)
if (!origin || allowedOrigins.includes(origin)) {
return origin
}
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
maxAge: 600,
credentials: true,
})
}

View File

@@ -0,0 +1,98 @@
import { Bindings } from '../bindings';
/**
* 回复通知邮件
*/
export async function sendCommentReplyNotification(
env: Bindings,
params: {
toEmail: string;
toName: string;
postTitle: string;
parentComment: string;
replyAuthor: string;
replyContent: string;
postUrl: string;
}
) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
const html = `
<div style="font-family: sans-serif; line-height: 1.6; color: #333;">
<p>Hi <b>${toName}</b></p>
<p>${replyAuthor} 回复了你在 <b>${postTitle}</b> 中的评论:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #e2e8f0; background: #f8fafc;">
${parentComment}
</blockquote>
<p>最新回复:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #3b82f6; background: #eff6ff;">
${replyContent}
</blockquote>
<p style="margin-top: 20px;">
<a href="${postUrl}" style="background: #3b82f6; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; display: inline-block;">
查看完整回复
</a>
</p>
<hr style="border: none; border-top: 1px solid #eee; margin-top: 30px;">
<p style="font-size: 12px; color: #999;">此邮件由系统自动发送,请勿直接回复。</p>
</div>
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
throw new Error('未配置邮件发送绑定或发件人地址');
}
await env.SEND_EMAIL.send({
to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL },
subject: `你在 example.com 上的评论有了新回复`,
html
});
}
/**
* 站长通知邮件
*/
export async function sendCommentNotification(
env: Bindings,
params: {
postTitle: string;
postUrl: string;
commentAuthor: string;
commentContent: string;
}
) {
const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env);
const html = `
<div style="font-family: sans-serif;">
<p><b>${commentAuthor}</b> 在文章《${postTitle}》下发表了评论:</p>
<div style="padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
${commentContent}
</div>
<p><a href="${postUrl}">点击跳转到文章</a></p>
</div>
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
throw new Error('未配置邮件发送绑定或发件人地址');
}
await env.SEND_EMAIL.send({
to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL },
subject: `新评论通知:${postTitle}`,
html
});
}
// 读取管理员通知邮箱:优先 KV 设置,其次环境变量
async function getAdminNotifyEmail(env: Bindings): Promise<string> {
if (env.CWD_CONFIG_KV) {
const val = await env.CWD_CONFIG_KV.get('settings:admin_notify_email');
if (val) return val;
}
if (env.EMAIL_ADDRESS) return env.EMAIL_ADDRESS;
throw new Error('未配置管理员通知邮箱');
}

View File

@@ -0,0 +1,19 @@
/**
* 默认 gravatar.com 前缀
*/
const DEFAULT_AVATAR_PREFIX = 'https://gravatar.com/avatar';
/**
* 辅助函数:生成 gravatar.com 头像地址 (MD5 算法)
* @param email - 邮箱地址
* @param prefix - 头像服务前缀,默认为 https://gravatar.com/avatar
*/
export const getCravatar = async (email: string, prefix?: string): Promise<string> => {
const cleanEmail = email.trim().toLowerCase();
const msgUint8 = new TextEncoder().encode(cleanEmail);
const hashBuffer = await crypto.subtle.digest('MD5', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
const avatarPrefix = prefix || DEFAULT_AVATAR_PREFIX;
return `${avatarPrefix}/${hashHex}?s=200&d=retro`;
};