feat(邮件通知): 重构邮件通知系统并增加配置选项

- 移除 Cloudflare Email 绑定,改为支持外部邮件网关
- 新增邮件通知全局开关及管理员/用户通知独立配置
- 在管理后台添加邮件通知配置界面
- 优化邮件发送逻辑,增加错误处理和日志记录
- 更新相关文档说明
This commit is contained in:
anghunk
2026-01-20 11:23:52 +08:00
parent 4146cfed6f
commit 2e177f58f5
11 changed files with 322 additions and 230 deletions

File diff suppressed because one or more lines are too long

View File

@@ -40,6 +40,12 @@ export type CommentSettingsResponse = {
adminEnabled: boolean;
};
export type EmailNotifySettingsResponse = {
globalEnabled: boolean;
adminEnabled: boolean;
userEnabled: boolean;
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
@@ -71,6 +77,18 @@ export function saveAdminEmail(email: string): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/email', { email });
}
export function fetchEmailNotifySettings(): Promise<EmailNotifySettingsResponse> {
return get<EmailNotifySettingsResponse>('/admin/settings/email-notify');
}
export function saveEmailNotifySettings(data: {
globalEnabled?: boolean;
adminEnabled?: boolean;
userEnabled?: boolean;
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/email-notify', data);
}
export function fetchCommentSettings(): Promise<CommentSettingsResponse> {
return get<CommentSettingsResponse>('/admin/settings/comments');
}

View File

@@ -41,6 +41,27 @@
<div class="card">
<h3 class="card-title">通知邮箱设置</h3>
<div class="form-item">
<label class="form-label">开启邮件通知</label>
<label class="switch">
<input v-model="emailGlobalEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">新评论通知管理员</label>
<label class="switch">
<input v-model="emailAdminEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">评论回复通知用户</label>
<label class="switch">
<input v-model="emailUserEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">管理员通知邮箱</label>
<input v-model="email" class="form-input" type="email" />
@@ -69,9 +90,14 @@ import {
saveAdminEmail,
fetchCommentSettings,
saveCommentSettings,
fetchEmailNotifySettings,
saveEmailNotifySettings,
} from "../api/admin";
const email = ref("");
const emailGlobalEnabled = ref(true);
const emailAdminEnabled = ref(true);
const emailUserEnabled = ref(true);
const commentAdminEmail = ref("");
const commentAdminBadge = ref("");
const avatarPrefix = ref("");
@@ -97,15 +123,19 @@ function showToast(msg: string, type: "success" | "error" = "success") {
async function load() {
loading.value = true;
try {
const [notifyRes, commentRes] = await Promise.all([
const [notifyRes, commentRes, emailNotifyRes] = await Promise.all([
fetchAdminEmail(),
fetchCommentSettings(),
fetchEmailNotifySettings(),
]);
email.value = notifyRes.email || "";
commentAdminEmail.value = commentRes.adminEmail || "";
commentAdminBadge.value = commentRes.adminBadge || "博主";
avatarPrefix.value = commentRes.avatarPrefix || "";
commentAdminEnabled.value = !!commentRes.adminEnabled;
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
emailAdminEnabled.value = !!emailNotifyRes.adminEnabled;
emailUserEnabled.value = !!emailNotifyRes.userEnabled;
} catch (e: any) {
message.value = e.message || "加载失败";
messageType.value = "error";
@@ -123,8 +153,15 @@ async function saveEmail() {
savingEmail.value = true;
message.value = "";
try {
const res = await saveAdminEmail(email.value);
showToast(res.message || "保存成功", "success");
const [emailRes] = await Promise.all([
saveAdminEmail(email.value),
saveEmailNotifySettings({
globalEnabled: emailGlobalEnabled.value,
adminEnabled: emailAdminEnabled.value,
userEnabled: emailUserEnabled.value,
}),
]);
showToast(emailRes.message || "保存成功", "success");
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";

View File

@@ -1,7 +1,14 @@
import { Context } from 'hono';
import { UAParser } from 'ua-parser-js';
import { Bindings } from '../../bindings';
import { sendCommentNotification, sendCommentReplyNotification, isValidEmail, getAdminNotifyEmail } from '../../utils/email';
import {
sendCommentNotification,
sendCommentReplyNotification,
isValidEmail,
getAdminNotifyEmail,
loadEmailNotificationSettings,
EmailNotificationSettings
} from '../../utils/email';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
@@ -68,9 +75,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
hasParent: parentId !== null && parentId !== undefined,
author,
email,
ip,
hasSendEmailBinding: !!c.env.SEND_EMAIL,
fromEmail: c.env.CF_FROM_EMAIL
ip
});
const uaParser = new UAParser(userAgent);
const uaResult = uaParser.getResult();
@@ -108,8 +113,20 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
ip
});
// 5. 发送邮件通知 (后台异步执行,不阻塞用户响应)
if (c.env.SEND_EMAIL && c.env.CF_FROM_EMAIL) {
let notifySettings: EmailNotificationSettings = {
globalEnabled: true,
adminEnabled: true,
userEnabled: true
};
try {
notifySettings = await loadEmailNotificationSettings(c.env);
} catch (e) {
console.error('PostComment:mailDispatch:loadEmailSettingsFailed', e);
}
if (!notifySettings.globalEnabled) {
console.log('PostComment:mailDispatch:disabledByGlobalConfig');
} else {
console.log('PostComment:mailDispatch:start', {
hasParent: parentId !== null && parentId !== undefined
});
@@ -140,25 +157,29 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
}
if (canSendUserMail && isValidEmail(parentComment.email)) {
console.log('PostComment:mailDispatch:userReply:send', {
toEmail: parentComment.email,
toName: parentComment.author
});
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();
console.log('PostComment:mailDispatch:userReply:logInserted', {
toEmail: parentComment.email
});
if (!notifySettings.userEnabled) {
console.log('PostComment:mailDispatch:userReply:disabledByConfig');
} else {
console.log('PostComment:mailDispatch:userReply:send', {
toEmail: parentComment.email,
toName: parentComment.author
});
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();
console.log('PostComment:mailDispatch:userReply:logInserted', {
toEmail: parentComment.email
});
}
}
}
} else {
@@ -167,17 +188,21 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
).first<{ created_at: string }>();
const canSendAdminMail = !adminEmailRow || (Date.now() - new Date(adminEmailRow.created_at).getTime() > 15 * 1000);
if (canSendAdminMail) {
console.log('PostComment:mailDispatch:admin:send');
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();
console.log('PostComment:mailDispatch:admin:logInserted');
if (!notifySettings.adminEnabled) {
console.log('PostComment:mailDispatch:admin:disabledByConfig');
} else {
console.log('PostComment:mailDispatch:admin:send');
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();
console.log('PostComment:mailDispatch:admin:logInserted');
}
}
if (!canSendAdminMail) {
console.log('PostComment:mailDispatch:admin:skippedByRateLimit');
@@ -187,13 +212,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
console.error("Mail Notification Failed:", mailError);
}
})());
} else {
console.log('PostComment:mailDispatch:skipNoBinding', {
hasSendEmailBinding: !!c.env.SEND_EMAIL,
fromEmail: c.env.CF_FROM_EMAIL
});
}
return c.json({ message: "Comment submitted. Awaiting moderation." });
} catch (e: any) {

View File

@@ -2,10 +2,8 @@ export type Bindings = {
CWD_DB: D1Database
CWD_AUTH_KV: KVNamespace;
ALLOW_ORIGIN: string
CF_FROM_EMAIL?: string
SEND_EMAIL?: {
send: (message: any) => Promise<any>
}
MAIL_GATEWAY_URL?: string
MAIL_GATEWAY_TOKEN?: string
ADMIN_NAME: string
ADMIN_PASSWORD: string
}

View File

@@ -2,7 +2,11 @@ import { Hono } from 'hono';
import { Bindings } from './bindings';
import { customCors } from './utils/cors';
import { adminAuth } from './utils/auth';
import { isValidEmail } from './utils/email';
import {
isValidEmail,
loadEmailNotificationSettings,
saveEmailNotificationSettings
} from './utils/email';
import { getComments } from './api/public/getComments';
import { postComment } from './api/public/postComment';
@@ -115,11 +119,11 @@ app.use('*', async (c, next) => {
});
app.use('/api/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
const corsMiddleware = customCors();
return corsMiddleware(c, next);
});
app.use('/admin/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
const corsMiddleware = customCors();
return corsMiddleware(c, next);
});
@@ -145,9 +149,37 @@ 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);
app.get('/admin/settings/email-notify', async (c) => {
try {
const settings = await loadEmailNotificationSettings(c.env);
return c.json(settings);
} catch (e: any) {
return c.json({ message: e.message || '加载邮件通知配置失败' }, 500);
}
});
app.put('/admin/settings/email-notify', async (c) => {
try {
const body = await c.req.json();
const globalEnabled =
typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined;
const adminEnabled =
typeof body.adminEnabled === 'boolean' ? body.adminEnabled : undefined;
const userEnabled =
typeof body.userEnabled === 'boolean' ? body.userEnabled : undefined;
await saveEmailNotificationSettings(c.env, {
globalEnabled,
adminEnabled,
userEnabled
});
return c.json({ message: '保存成功' });
} catch (e: any) {
return c.json({ message: e.message || '保存失败' }, 500);
}
});
app.get('/admin/settings/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);

View File

@@ -4,9 +4,138 @@ export function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* 回复通知邮件
*/
const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled';
const EMAIL_NOTIFY_ADMIN_KEY = 'email_notify_admin_enabled';
const EMAIL_NOTIFY_USER_KEY = 'email_notify_user_enabled';
type MailGatewayPayload = {
to: string[];
subject: string;
html: string;
};
async function dispatchMail(env: Bindings, payload: MailGatewayPayload) {
if (!env.MAIL_GATEWAY_URL) {
console.error('MailGateway:missingUrl');
return;
}
try {
const res = await fetch(env.MAIL_GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.MAIL_GATEWAY_TOKEN ? { 'X-Auth-Token': env.MAIL_GATEWAY_TOKEN } : {})
},
body: JSON.stringify(payload)
});
if (!res.ok) {
console.error('MailGateway:sendFailed', {
status: res.status,
statusText: res.statusText
});
}
} catch (e: any) {
console.error('MailGateway:error', {
message: e?.message || String(e)
});
}
}
export type EmailNotificationSettings = {
globalEnabled: boolean;
adminEnabled: boolean;
userEnabled: boolean;
};
function parseEnabled(raw: string | undefined, defaultValue: boolean) {
if (raw === undefined) return defaultValue;
return raw === '1';
}
export async function loadEmailNotificationSettings(
env: Bindings
): Promise<EmailNotificationSettings> {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const keys = [EMAIL_NOTIFY_GLOBAL_KEY, EMAIL_NOTIFY_ADMIN_KEY, EMAIL_NOTIFY_USER_KEY];
const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
const map = new Map<string, string>();
for (const row of results) {
if (row && row.key) {
map.set(row.key, row.value);
}
}
const globalEnabled = parseEnabled(map.get(EMAIL_NOTIFY_GLOBAL_KEY), true);
const adminEnabled = parseEnabled(map.get(EMAIL_NOTIFY_ADMIN_KEY), true);
const userEnabled = parseEnabled(map.get(EMAIL_NOTIFY_USER_KEY), true);
return {
globalEnabled,
adminEnabled,
userEnabled
};
}
export async function saveEmailNotificationSettings(
env: Bindings,
settings: {
globalEnabled?: boolean;
adminEnabled?: boolean;
userEnabled?: boolean;
}
) {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const entries: { key: string; value: string | undefined }[] = [
{
key: EMAIL_NOTIFY_GLOBAL_KEY,
value:
typeof settings.globalEnabled === 'boolean'
? settings.globalEnabled
? '1'
: '0'
: undefined
},
{
key: EMAIL_NOTIFY_ADMIN_KEY,
value:
typeof settings.adminEnabled === 'boolean'
? settings.adminEnabled
? '1'
: '0'
: undefined
},
{
key: EMAIL_NOTIFY_USER_KEY,
value:
typeof settings.userEnabled === 'boolean'
? settings.userEnabled
? '1'
: '0'
: undefined
}
];
for (const entry of entries) {
if (entry.value !== undefined) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
.bind(entry.key, entry.value)
.run();
}
}
}
export async function sendCommentReplyNotification(
env: Bindings,
params: {
@@ -24,9 +153,7 @@ export async function sendCommentReplyNotification(
console.log('EmailReplyNotification:start', {
toEmail,
toName,
postTitle,
fromEmail: env.CF_FROM_EMAIL,
hasSendBinding: !!env.SEND_EMAIL
postTitle
});
const html = `
@@ -70,22 +197,13 @@ export async function sendCommentReplyNotification(
</div>
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
console.error('EmailReplyNotification:missingBinding', {
hasSendBinding: !!env.SEND_EMAIL,
fromEmail: env.CF_FROM_EMAIL
});
throw new Error('未配置邮件发送绑定或发件人地址');
}
if (!isValidEmail(toEmail)) {
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
return;
}
await env.SEND_EMAIL.send({
await dispatchMail(env, {
to: [toEmail],
from: env.CF_FROM_EMAIL,
subject: `评论回复 - ${postTitle}`,
html
});
@@ -110,13 +228,6 @@ export async function sendCommentNotification(
const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env);
console.log('EmailAdminNotification:start', {
toEmail,
postTitle,
fromEmail: env.CF_FROM_EMAIL,
hasSendBinding: !!env.SEND_EMAIL
});
const html = `
<div style="background-color:#f4f4f5;padding:24px 0;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
@@ -153,38 +264,16 @@ export async function sendCommentNotification(
</div>
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
console.error('EmailAdminNotification:missingBinding', {
hasSendBinding: !!env.SEND_EMAIL,
fromEmail: env.CF_FROM_EMAIL
});
throw new Error('未配置邮件发送绑定或发件人地址');
}
if (!isValidEmail(toEmail)) {
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });
return;
}
console.log('EmailAdminNotification:send:start', {
to: toEmail,
from: env.CF_FROM_EMAIL
await dispatchMail(env, {
to: [toEmail],
subject: `新评论提醒 - ${postTitle}`,
html
});
try {
await env.SEND_EMAIL.send({
to: [toEmail],
from: env.CF_FROM_EMAIL,
subject: `新评论提醒 - ${postTitle}`,
html
});
} catch (sendError: any) {
console.error('EmailAdminNotification:send:error', {
error: sendError.message,
to: toEmail,
from: env.CF_FROM_EMAIL
});
throw sendError;
}
console.log('EmailAdminNotification:sent', {
toEmail

View File

@@ -30,7 +30,6 @@ onMounted(async () => {
const comments = new window.CWDComments({
el: commentsRoot.value,
apiBaseUrl,
postSlug: window.location.pathname,
});
comments.mount();

View File

@@ -14,13 +14,12 @@
### 查询参数
| 名称 | 位置 | 类型 | 必填 | 说明 |
| -------------- | ------ | ------- | ---- | -------------------------------------------------------------------- |
| `post_slug` | query | string | 是 | 文章唯一标识符,与前端配置中的 `postSlug` 保持一致 |
| `page` | query | integer | 否 | 页码,默认 `1` |
| `limit` | query | integer | 否 | 每页数量,默认 `20`,最大 `50` |
| `nested` | query | string | 否 | 是否返回嵌套结构,默认 `'true'` |
| `avatar_prefix`| query | string | 否 | 覆盖头像地址前缀,优先级高于服务端设置和前端配置 |
| 名称 | 位置 | 类型 | 必填 | 说明 |
| --------------- | ----- | ------- | ---- | --------------------------------------------------- |
| `post_slug` | query | string | 是 | `window.location.origin + window.location.pathname` |
| `page` | query | integer | 否 | 页码,默认 `1` |
| `limit` | query | integer | 否 | 每页数量,默认 `20`,最大 `50` |
| `nested` | query | string | 否 | 是否返回嵌套结构,默认 `'true'` |
### 成功响应

View File

@@ -6,7 +6,7 @@
* 拥有一个 Node.js 运行环境,版本 >= 22本地部署需要
* 拥有一个域名并托管在 Cloudflare 上(这个不是必须项,但可以提高国内访问速度,也更方便)
后端项目目录为 `cwd-comments-api/`,基于 Cloudflare Workers + D1 + KV 实现。
后端项目目录为 `/cwd-comments-api/`,基于 Cloudflare Workers + D1 + KV 实现。
## 部署
@@ -48,11 +48,11 @@ npm install
```jsonc
"d1_databases": [
{
"binding": "CWD_DB",
"database_name": "CWD_DB",
"database_id": "xxxxxx" // D1 数据库 ID
}
{
"binding": "CWD_DB",
"database_name": "CWD_DB",
"database_id": "xxxxxx" // D1 数据库 ID
}
]
```
@@ -68,10 +68,10 @@ npm install
```jsonc
"kv_namespaces": [
{
"binding": "CWD_AUTH_KV",
"id": "xxxxxxx" // KV 存储 ID
}
{
"binding": "CWD_AUTH_KV",
"id": "xxxxxxx" // KV 存储 ID
}
]
```
@@ -155,7 +155,7 @@ wrangler dev
- `binding` 必须为 `CWD_DB`,与代码中的 `env.CWD_DB` 一致。
- `database_name` 和 `database_id` 根据 Cloudflare 实际创建结果填写。
数据库结构定义见 [`schemas/comment.sql`](../../cwd-comments-api/schemas/comment.sql)
数据库结构定义见 `schemas/comment.sql`。
### KV 存储
@@ -191,15 +191,15 @@ KV 主要用于:
所需环境变量如下表所示。
| 名称 | 类型 | 描述 |
| ----------------- | ----------- | -------------------------------------------------------------------- |
| `CWD_DB` | D1 绑定 | 评论数据存储数据库 |
| `CWD_AUTH_KV` | KV 绑定 | 管理员登录 Token、登录尝试计数等 |
| `ALLOW_ORIGIN` | string | 预留的允许跨域来源配置,目前实现中仍使用 `*` |
| `CF_FROM_EMAIL` | string | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置)选填 |
| `SEND_EMAIL` | send_email | Cloudflare Email 发送绑定,供通知邮件使用 |
| `ADMIN_NAME` | string | 管理员登录名称 |
| `ADMIN_PASSWORD` | string | 管理员登录密码 |
| 名称 | 类型 | 描述 |
| ------------------ | ----------- | --------------------------------------------------------------------- |
| `CWD_DB` | D1 绑定 | 评论数据存储数据库 |
| `CWD_AUTH_KV` | KV 绑定 | 管理员登录 Token、登录尝试计数等 |
| `ALLOW_ORIGIN` | string | 预留的允许跨域来源配置,目前实现中仍使用 `*` |
| `MAIL_GATEWAY_URL` | string | 外部邮件网关 HTTP 地址,由此网关转发到 QQ SMTP 或其他邮箱服务(可选) |
| `MAIL_GATEWAY_TOKEN` | string | 调用外部邮件网关使用的鉴权 Token可选 |
| `ADMIN_NAME` | string | 管理员登录名称 |
| `ADMIN_PASSWORD` | string | 管理员登录密码 |
在 Cloudflare 控制台中配置方式:
@@ -207,96 +207,3 @@ KV 主要用于:
- 在 `Environment Variables` 中添加 `ADMIN_NAME`、`ADMIN_PASSWORD` 等变量
- 在 `D1 Databases` 中绑定 `CWD_DB`
- 在 `KV Namespaces` 中绑定 `CWD_AUTH_KV`
- 在 `Email` 中绑定 `SEND_EMAIL`(如需启用邮件通知)
**注:** 需要在 Cloudflare 控制面板中为 Email 路由开启发送权限并配置发件人域和地址,并在 `wrangler.jsonc` 中为 Worker 添加 `send_email` 绑定,以便在代码中通过 `env.SEND_EMAIL.send()` 直接发信。
## 发信设置
手动在 `wrangler.jsonc` 中添加 `send_email` 绑定,以便在代码中通过 `env.SEND_EMAIL.send()` 直接发信。
```jsonc
{
...
"send_email": [
{
"name": "SEND_EMAIL"
}
],
...
}
```
参数 `CF_FROM_EMAIL` 这里填写的邮箱是你绑定域名后创建的 Email 路由,两者需保持一致。
## 中间件配置说明
后端使用 Hono 框架,在入口文件中统一配置了 CORS 和管理员认证中间件。
入口文件位置:[`cwd-comments-api/src/index.ts`](../../cwd-comments-api/src/index.ts)
### CORS 中间件
当前实现位于 [`cwd-comments-api/src/utils/cors.ts`](../../cwd-comments-api/src/utils/cors.ts),对 `/api/*` 和 `/admin/*` 路径统一应用:
- 允许来源:`*`
- 允许方法:`GET, POST, PUT, DELETE, OPTIONS`
- 允许请求头:`Content-Type, Authorization`
- 暴露响应头:`Content-Length`
- 不允许携带凭证(`credentials: false`
这意味着:
- 评论组件和管理后台可以在任意域名下通过 HTTP 调用后端接口,无需浏览器端额外跨域配置。
- 由于不允许跨域携带 Cookie认证完全通过 `Authorization: Bearer <token>` 头完成。
代码中预留了 `ALLOW_ORIGIN` 绑定,目前默认行为是允许所有来源。如果你有严格的安全需求,可以在此基础上自定义 CORS 逻辑,将 `origin` 收紧到指定域名。
### 管理员认证中间件
管理员认证中间件位于 [`cwd-comments-api/src/utils/auth.ts`](../../cwd-comments-api/src/utils/auth.ts),对 `/admin/*` 路径统一生效(登录接口除外):
- 从请求头 `Authorization` 中解析 Bearer Token。
- 在 `CWD_AUTH_KV` 中校验 `token:<key>` 对应的会话信息。
- Token 由 `/admin/login` 接口生成,有效期为 24 小时。
认证失败时返回:
- 状态码:`401`
- 响应体:`{ "message": "Unauthorized" }` 或 `Token expired or invalid`
## 日志配置与规范
后端主要通过 `console.log` 输出结构化日志,便于在 Cloudflare 控制台或日志采集系统中查看。
### 请求级别日志
在入口中为所有请求记录起止日志:
- `Request:start`
- `method`HTTP 方法
- `path`:请求路径
- `url`:完整 URL
- `hasDb`:是否成功注入 D1 绑定
- `hasAuthKv`:是否成功注入 KV 绑定
- `Request:end`
- `method`HTTP 方法
- `path`:请求路径
### 业务级别日志
示例(评论提交流程):
- `PostComment:request`:记录 `postSlug`、是否为回复、邮箱是否存在、IP 等信息。
- `PostComment:inserted`:记录评论已写入数据库。
- `PostComment:mailDispatch:*`:记录邮件通知相关流程和限流结果。
错误情况:
- 统一使用 `console.error` 输出错误对象,例如邮件发送失败或数据库写入异常。
### 日志使用建议
- 不在日志中输出管理员密码、完整 Token 等敏感信息。
- 如果接入外部日志系统,可以基于日志前缀(如 `Request:*`、`PostComment:*`)做过滤和告警。
- 在调试阶段可以保留日志,生产环境如需减少日志量,可根据需要在代码中调整输出。

View File

@@ -9,7 +9,6 @@
* @param {string} config.postSlug - 文章标识符
* @param {string} config.postTitle - 文章标题(可选)
* @param {string} config.postUrl - 文章 URL可选
* @param {string} config.avatarPrefix - 头像服务前缀(可选)
* @returns {Object}
*/
export function createApiClient(config) {
@@ -29,11 +28,6 @@ export function createApiClient(config) {
nested: 'true'
});
// 如果配置了头像前缀,添加到请求参数
if (config.avatarPrefix) {
params.set('avatar_prefix', config.avatarPrefix);
}
const response = await fetch(`${baseUrl}/api/comments?${params}`);
if (!response.ok) {
throw new Error(`获取评论失败: ${response.status} ${response.statusText}`);