为聊天机器人添加无密码登录和 AI 评论系统
我的个人网站右下角有一个基于 RAG 的 AI 聊天助手。最近我希望访客能通过这个聊天窗口直接留言评论。传统评论系统需要复杂的后端和数据库,用户还得注册密码。为了保持轻量和低摩擦,我设计了一套邮件 OTP 无密码认证,配合两种评论方式:显式的评论按钮和 AI 自动识别。
整个系统构建在 Cloudflare 生态上:前端是原生 JavaScript,后端是 Cloudflare Worker,数据存在 KV 中,邮件通过 Resend API 发送。评论在构建时从 KV 拉取,增量合并到每篇文章对应的 JSON 文件中,并在博客页面上渲染展示。
整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 浏览器 (chat-widget.js) │
│ │
│ 用户输入 ──▶ [发送] 按钮 ──▶ /api/chat(AI + RAG) │
│ [评论] 按钮 ──▶ /api/comment(直接保存) │
│ AI 同时通过 [COMMENT] 标记自动识别评论 │
│ │
│ 登录流程 ──▶ 输入邮箱 ──▶ 输入验证码 ──▶ 设置昵称(新用户) │
│ localStorage 持久化认证状态 │
└─────────────┬───────────────────────────────────────────────────┘
│ credentials: 'include' (跨域携带 Cookie)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (embed-worker.js) │
│ │
│ /api/auth/send-code ──▶ 生成 OTP ──▶ Resend API 发邮件 │
│ /api/auth/verify-code ──▶ 验证 OTP ──▶ 创建 Session Cookie │
│ /api/auth/me ──▶ 检查会话状态 │
│ /api/comment ──▶ 验证身份 + 直接存入 KV(显式评论) │
│ /api/chat ──▶ [COMMENT] 标记 ──▶ 存 KV / 返回 AI 回复 │
│ │
│ Session Cookie: HttpOnly; Secure; SameSite=None │
└─────────────┬───────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare KV (AI_AUTH_KV) OpenAI API │
│ ├─ otp:{email} (TTL 5min) gpt-4o-mini │
│ ├─ sess:{token} (TTL 7days) │
│ ├─ user:{email} Resend API │
│ └─ comment:{page}:{ts}:{uuid} (发送验证码邮件) │
└─────────────┬───────────────────────────────────────────────────┘
│ build.sh(拉取 → 合并 → 展示)
▼
┌─────────────────────────────────────────────────────────────────┐
│ 构建流水线 │
│ pull-comments.mjs ──▶ .cache/comments.json │
│ merge-comments.mjs ──▶ blog/comments/2026/foo.json(按页分文件)│
│ post.html ──▶ 获取对应页面的 JSON ──▶ 渲染评论区 │
└─────────────────────────────────────────────────────────────────┘
邮件 OTP 无密码认证
发送验证码
用户在聊天窗口点击登录按钮,输入邮箱后,Worker 生成 6 位 OTP 并通过 Resend API 发送:
// 生成加密安全的 6 位数字
function generateOTP() {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return String(array[0] % 1000000).padStart(6, '0');
}
// 存入 KV,5 分钟过期
const code = generateOTP();
await env.AI_AUTH_KV.put(
`otp:${email}`,
JSON.stringify({ code, attempts: 0 }),
{ expirationTtl: 300 }
);
// 60 秒冷却,防止邮件轰炸
await env.AI_AUTH_KV.put(`cooldown:${email}`, '1', { expirationTtl: 60 });
// 通过 Resend 发送邮件
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}` },
body: JSON.stringify({
from: 'Yuxu.ge <[email protected]>',
to: [email],
subject: 'Your verification code',
html: `<p>Your code: <strong>${code}</strong></p>
<p>Expires in 5 minutes.</p>`,
}),
});
验证 OTP 并创建会话
验证码正确后,Worker 生成 32 字节的 hex 会话令牌,通过 Set-Cookie 返回:
// 验证 OTP(最多 5 次尝试)
const otpRecord = await env.AI_AUTH_KV.get(`otp:${email}`, { type: 'json' });
if (otpRecord.attempts >= 5) {
await env.AI_AUTH_KV.delete(`otp:${email}`);
return jsonResponse({ error: 'Too many attempts' }, 429);
}
if (otpRecord.code !== code) {
otpRecord.attempts += 1;
await env.AI_AUTH_KV.put(`otp:${email}`, JSON.stringify(otpRecord), { expirationTtl: 300 });
return jsonResponse({ error: 'Invalid code', attemptsLeft: 5 - otpRecord.attempts }, 400);
}
// 验证通过,创建会话
const token = generateSessionToken(); // 16 字节随机 hex
await env.AI_AUTH_KV.put(
`sess:${token}`,
JSON.stringify({ email, name: user.name }),
{ expirationTtl: 7 * 24 * 3600 }
);
// 设置 HttpOnly Cookie
return jsonResponse({ ok: true, name: user.name }, 200, {
'Set-Cookie': `session=${token}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=604800`
});
前端认证状态持久化
为了解决跨域场景下 Cookie 不可靠的问题,我将认证状态同时存入 localStorage,页面加载时立即恢复 UI:
async init() {
// 1. 从 localStorage 立即恢复(UI 无闪烁)
this.restoreAuthFromStorage();
// 2. 后台向服务器验证会话有效性
this.checkAuth();
}
restoreAuthFromStorage() {
const stored = localStorage.getItem('chat_auth_user');
if (stored) {
this.authUser = JSON.parse(stored);
this.updateAuthUI(); // 立即显示用户名、绿色图标
}
}
async checkAuth() {
const res = await fetch(`${authApi}/me`, { credentials: 'include' });
const data = await res.json();
if (data.loggedIn) {
this.authUser = { email: data.email, name: data.name };
} else {
this.authUser = null; // 服务器说没登录,以服务器为准
}
this.saveAuthToStorage();
this.updateAuthUI();
}
双模式评论提交
评论系统提供两种提交方式,让用户既有明确的控制权,也能享受 AI 自动识别的便利。
显式评论按钮
登录后,发送按钮旁边会出现一个绿色的评论按钮。点击它会将消息直接发送到 /api/comment——不经过 AI,保证存为评论:
async submitComment(text) {
const response = await fetch('/api/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
text: text.trim(),
pageUrl: window.location.pathname,
}),
});
if (response.ok) {
this.addSystemMessage('Comment saved!');
}
}
Worker 端验证身份后直接存入 KV:
// POST /api/comment
const user = await getAuthUser(request, env);
if (!user) return jsonResponse({ error: 'Not authenticated' }, 401);
const { text, pageUrl } = await request.json();
const commentKey = `comment:${encodeURIComponent(pageUrl)}:${Date.now()}:${crypto.randomUUID()}`;
await env.AI_AUTH_KV.put(commentKey, JSON.stringify({
email: user.email, name: user.name,
text: text.trim(), pageUrl, timestamp: Date.now(),
}));
AI 自动识别(通过发送按钮)
当用户通过正常的发送按钮发消息时,AI 也会尝试分类。Worker 在 System Prompt 中追加分类指令:
if (user) {
systemPrompt += `\n\n用户 "${user.name}" 已登录,当前页面: ${pageUrl}\n` +
'如果用户的消息是评论、感想或反馈(不是提问),' +
'请在回复的第一行加上 [COMMENT] 标记。' +
'对于提问和正常对话不要加此标记。';
}
收到 LLM 响应后,Worker 检测 [COMMENT] 前缀:
let reply = data.choices[0].message.content;
let isComment = false;
if (user && reply.startsWith('[COMMENT]')) {
isComment = true;
reply = reply.replace(/^\[COMMENT\]\s*/, '');
// 保存用户的原始消息到 KV
const lastUserMsg = messages.filter(m => m.role === 'user').pop();
const commentKey = `comment:${encodeURIComponent(pageUrl)}:${Date.now()}:${crypto.randomUUID()}`;
await env.AI_AUTH_KV.put(commentKey, JSON.stringify({
email: user.email, name: user.name,
text: lastUserMsg.content.trim(), pageUrl, timestamp: Date.now(),
}));
}
return new Response(JSON.stringify({ reply, isComment }));
双模式的好处:
- 绿色评论按钮:点击即保证评论被保存
- 红色发送按钮:自然输入——"文章写得很好!"会自动保存,"用了什么技术栈?"则正常回答
构建流水线:拉取、合并、展示
评论通过三步流水线从 KV 流转到网站,集成在 build.sh 中。
第一步:从 KV 拉取
pull-comments.mjs 列出所有 comment: 前缀的 key,逐条获取值,按页面 URL 分组后写入 .cache/comments.json:
// 列出所有评论 key
const keysJson = execSync(
`npx wrangler kv key list --prefix="comment:" --namespace-id="${namespaceId}" --remote`
);
const keys = JSON.parse(keysJson);
// 逐条拉取并按 pageUrl 分组
for (const keyObj of keys) {
const value = execSync(`npx wrangler kv key get "${keyObj.name}" ...`);
const comment = JSON.parse(value);
comments[comment.pageUrl].push({ name: comment.name, text: comment.text, timestamp: comment.timestamp });
}
fs.writeFileSync('.cache/comments.json', JSON.stringify(comments, null, 2));
// 批量删除(一次 API 调用,而非逐条删除)
const keyNames = keys.map(k => k.name);
fs.writeFileSync(bulkFile, JSON.stringify(keyNames));
execSync(`npx wrangler kv bulk delete "${bulkFile}" --namespace-id="${namespaceId}" --remote --force`);
最初的版本使用逐条删除(在循环中调用 wrangler kv key delete),非常慢——每次调用都要启动一个新进程并发起网络请求。改用 wrangler kv bulk delete 配合 key 名称的 JSON 文件后,删除时间从分钟级降到了秒级。
第二步:增量合并
merge-comments.mjs 读取 .cache/comments.json,将新评论增量合并到 blog/comments/ 下的每页 JSON 文件中:
// 将 pageUrl 映射到文件路径:
// "/blog/2026/foo.html" → "blog/comments/2026/foo.json"
function pageUrlToFile(pageUrl) {
let rel = pageUrl.replace(/^\/blog\//, '').replace(/\.html$/, '');
return path.join('blog', 'comments', rel + '.json');
}
// 通过 timestamp 去重,防止重复拉取
const existingTimestamps = new Set(existing.map(c => c.timestamp));
const newOnes = incoming.filter(c => !existingTimestamps.has(c.timestamp));
const merged = [...existing, ...newOnes].sort((a, b) => a.timestamp - b.timestamp);
为什么按页分文件而不是一个大 comments.json?
- 增量更新:新评论合并而非覆盖。手动从 JSON 文件中删除一条评论不会在下次构建时被恢复(因为 KV 中的评论拉取后就删除了)。
- 更小的请求:每篇文章只加载自己的评论,而非整个站点的。
- 结构清晰:
blog/comments/2026/chat-auth-comment-system.json自然对应文章 URL。
第三步:在文章页面展示
动态查看器(post.html)和静态 HTML 页面都会获取对应的评论 JSON 文件,在文章下方渲染评论区:
// 根据当前页面推导评论文件 URL
// 静态页面: /blog/2026/foo.html → /blog/comments/2026/foo.json
// 动态查看器: postName "posts/2026/foo" → /blog/comments/2026/foo.json
fetch(commentsUrl)
.then(r => r.ok ? r.json() : null)
.then(comments => {
if (!comments || !comments.length) return;
section.style.display = 'block';
section.innerHTML = '<h2>Comments (' + comments.length + ')</h2>' +
comments.map(c => `
<div class="comment-item">
<strong>${c.name}</strong>
<span>${new Date(c.timestamp).toLocaleDateString()}</span>
<p>${c.text}</p>
</div>
`).join('');
});
构建集成
build.sh 中的完整流程:
# 从 KV 拉取评论(如果配置了)
if [ -n "$CF_AUTH_KV_NAMESPACE_ID" ]; then
node _tools/pull-comments.mjs # 拉取 + 批量删除
fi
node _tools/merge-comments.mjs # 增量合并到每页 JSON 文件
# ... 构建搜索索引、静态 HTML 等
node _tools/build.js # 静态页面包含评论渲染 JS
跨域踩坑:SameSite Cookie
这是整个项目最大的坑。我的网站在 GitHub Pages (yuxu.ge),API 在 Cloudflare Worker (yuxu.ge/api/*)。生产环境没问题,但本地开发时 localhost:8080 调用 yuxu.ge 的 API 是跨站请求。
问题链:
- Worker 设置了
SameSite=Lax的 Cookie - 浏览器对跨站 fetch 请求不会携带
SameSite=LaxCookie - 登录成功后内存中有状态,但切换页面后
checkAuth()调用 API 拿不到 Cookie - 服务器返回
{ loggedIn: false },用户看起来就像没登录
解决方案分两步:
1. Cookie 改为 SameSite=None(需搭配 Secure):
session=token; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=604800
2. localStorage 做本地缓存:即使 Cookie 由于某些原因未携带,UI 也能从 localStorage 立即恢复认证状态,然后在后台静默验证。
安全措施
- 邮箱白名单:通过环境变量配置,测试阶段只允许指定邮箱
- 60 秒 OTP 冷却:防止邮件轰炸
- 5 次验证上限:超过后需重新请求验证码
- HttpOnly + Secure Cookie:JS 无法读取,HTTPS only
- CORS 白名单:只允许
yuxu.ge、www.yuxu.ge、localhost:8080 - pageUrl 正则校验:
/^\/[\w\-\/\.]*$/,防止注入非法路径 - KV 前缀隔离:
comment:前缀确保拉取/删除脚本不会触碰otp:*、sess:*或user:*的数据
经验总结
同时提供显式和隐式操作:绿色评论按钮给用户确定性("我点了评论,一定会被保存"),而 AI 自动识别处理自然对话流。用户不应该被迫思考模式切换。
批量操作优于循环:在循环中逐条调用
wrangler kv key delete会启动 N 个进程、N 次网络往返。wrangler kv bulk delete配合 JSON 文件一次搞定。使用 API 前先检查是否存在批量版本。按页分文件优于单体 JSON:将评论拆分到
blog/comments/2026/foo.json支持增量合并、更小的网络请求和简单的手动管理(删文件或编辑条目)。深入理解
SameSiteCookie 策略:Lax在同站场景够用,但只要涉及跨站 fetch(尤其是本地开发),就必须用None+Secure。双层持久化(Cookie + localStorage)更可靠:Cookie 负责服务端认证,localStorage 负责客户端即时 UI 恢复。两者互补。
Cloudflare KV 可以当轻量级消息队列:评论写入 KV,脚本拉取后批量删除。虽然不是真正的消息队列,但对个人网站完全够用。
邮件 OTP 的体验比密码好:用户不需要记密码,只需要能收邮件。对于低频访问的个人网站,这种认证方式摩擦最低。