← Back to Blog
EN中文

为聊天机器人添加无密码登录和 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 是跨站请求。

问题链:

  1. Worker 设置了 SameSite=Lax 的 Cookie
  2. 浏览器对跨站 fetch 请求不会携带 SameSite=Lax Cookie
  3. 登录成功后内存中有状态,但切换页面后 checkAuth() 调用 API 拿不到 Cookie
  4. 服务器返回 { 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.gewww.yuxu.gelocalhost:8080
  • pageUrl 正则校验/^\/[\w\-\/\.]*$/,防止注入非法路径
  • KV 前缀隔离comment: 前缀确保拉取/删除脚本不会触碰 otp:*sess:*user:* 的数据

经验总结

  1. 同时提供显式和隐式操作:绿色评论按钮给用户确定性("我点了评论,一定会被保存"),而 AI 自动识别处理自然对话流。用户不应该被迫思考模式切换。

  2. 批量操作优于循环:在循环中逐条调用 wrangler kv key delete 会启动 N 个进程、N 次网络往返。wrangler kv bulk delete 配合 JSON 文件一次搞定。使用 API 前先检查是否存在批量版本。

  3. 按页分文件优于单体 JSON:将评论拆分到 blog/comments/2026/foo.json 支持增量合并、更小的网络请求和简单的手动管理(删文件或编辑条目)。

  4. 深入理解 SameSite Cookie 策略Lax 在同站场景够用,但只要涉及跨站 fetch(尤其是本地开发),就必须用 None + Secure

  5. 双层持久化(Cookie + localStorage)更可靠:Cookie 负责服务端认证,localStorage 负责客户端即时 UI 恢复。两者互补。

  6. Cloudflare KV 可以当轻量级消息队列:评论写入 KV,脚本拉取后批量删除。虽然不是真正的消息队列,但对个人网站完全够用。

  7. 邮件 OTP 的体验比密码好:用户不需要记密码,只需要能收邮件。对于低频访问的个人网站,这种认证方式摩擦最低。