上一篇《网站改造记(一)》记录了从纯静态到前后端分离的过程——加了 Express 后端和 PostgreSQL 数据库,支持了 Google/GitHub OAuth 登录、评论和收藏。改完之后用了一段时间,又发现了新问题:不是每个人都有 Google 或 GitHub 账号,而且微信里 Google OAuth 直接被拒;主题方面一直在折腾,深色模式改了又改,始终找不到满意的方向。这一篇就是解决这两个问题的过程。
邮箱登录:给用户多一个选择
为什么需要邮箱登录
step1 上线后,朋友们的反馈很直接:「我没有 GitHub 账号」「我不想用 Google 登录」。确实,OAuth 虽然方便,但前提是用户得有对应的第三方账号。再加上微信内 Google OAuth 被拒的坑(上一篇详细写过),只靠 OAuth 显然不够。
邮箱 + 密码是最朴素也最通用的认证方式。几乎所有人都有邮箱,注册流程也符合大多数人的认知习惯。
方案选择:传统验证链接
邮箱登录有几种常见方案:
- Magic Link:发一封含登录链接的邮件,点击直接登录,不设密码
- OTP 验证码:发一个 6 位数字验证码,用户手动输入
- 传统方式:注册时设密码,发邮件验证邮箱,之后用密码登录
最终选了传统方式。原因很简单:Magic Link 每次登录都要去翻邮箱,体验不好;OTP 验证码需要额外的输入步骤;传统方式虽然注册流程长一点,但登录体验最好——输入邮箱密码就完事了,和已有的 OAuth 流程也能自然融合。
完整用户流程
注册:输入邮箱和密码 → 服务端用 bcrypt(12 轮加盐)哈希密码,用 crypto.randomBytes(32) 生成 64 字符的验证 token → 发送验证邮件 → 用户点击邮件中的链接 → token 校验通过,标记邮箱已验证 → 跳转到完成注册页面(设置 accountId 和 displayName,和 OAuth 首次登录一样)。
登录:输入邮箱和密码 → 服务端校验密码并检查邮箱是否已验证 → 签发 JWT(access_token 15 分钟 + refresh_token 7 天),写入 HttpOnly Cookie。这部分和 OAuth 登录走的是同一条路。
忘记密码:输入邮箱 → 生成一个有效期 1 小时的重置 token → 发送重置邮件 → 用户点击链接 → 输入新密码 → 完成重置。重置成功后,该用户所有未使用的旧重置 token 自动失效。
安全设计
几个关键的安全决策:
- 密码哈希:bcrypt 12 轮,不存明文,不用 MD5/SHA 这些不适合密码的算法
- Token 生成:
crypto.randomBytes(32).toString('hex')— 32 字节随机数转成 64 字符 hex,密码学安全 - Token 一次性使用:用过就标记
usedAt,不删除(保留审计记录) - 速率限制:认证端点 60 秒内最多 10 次,密码重置端点额外限制 60 秒内最多 3 次
- 防止用户枚举:忘记密码无论邮箱是否存在都返回「如果该邮箱已注册,重置链接将发送到该邮箱」
- OAuth 冲突处理:如果用户已用 Google 登录过某个邮箱,再用同一个邮箱注册时会提示「该邮箱已通过 Google 登录,请使用 Google 登录」
数据库变更
User 表新增三个字段:
passwordHash String? // 密码哈希(OAuth 用户为 null)
emailVerified Boolean // 邮箱是否已验证(默认 false)
emailVerifiedAt DateTime? // 验证时间
新增 EmailToken 表:
token String @unique // 64 字符 hex
type String // 'verify' 或 'reset'
expiresAt DateTime // 验证 24 小时,重置 1 小时
usedAt DateTime? // 使用时间(null = 未使用)
userId String // 关联用户
token 不删除只标记使用状态,方便追溯问题。索引建在 token(查找用)和 [userId, type](批量失效用)上。
前端:三视图登录抽屉
登录面板做成了底部滑出的抽屉样式,通过 view 状态切换三个视图:
| 视图 | 内容 | 切换链接 |
|---|---|---|
| login(默认) | Google/GitHub OAuth 按钮 + 分割线 + 邮箱密码表单 | → 忘记密码 / 注册 |
| register | 邮箱 + 密码 + 确认密码表单 | → 返回登录 |
| forgot | 邮箱表单 + 提示文字 | → 返回登录 |
OAuth 按钮是普通的 <a> 标签,点击后整页跳转到第三方授权页面;邮箱登录则是 AJAX 提交,成功后直接刷新用户状态,不需要页面跳转。
服务端渲染落地页
邮件里的链接直接指向后端 API 端点(GET 请求),服务端处理完验证/重置逻辑后直接返回 HTML 页面——不依赖前端 JS,在任何浏览器里打开都能用。
三个 SSR 页面:
- 验证结果页:显示验证成功(绿色勾)或失败(红色叉),成功时提供「去完成注册」按钮
- 密码重置表单页:原生 HTML 表单,输入新密码和确认密码,POST 提交
- 重置结果页:显示重置成功或失败
这个设计兼顾了两种使用场景:前端 SPA 可以调 POST API 做无刷新操作,用户直接点击邮件链接则走 GET → SSR HTML 的路径。
AWS SES:从 0 到 1 搭建邮件发送服务
邮箱登录做好了,但邮件怎么发出去?自己搭 SMTP 服务器?不现实,送达率和维护成本都是问题。第三方邮件服务的话,项目已经在用 AWS(EC2、S3、SSM),SES 是最自然的选择。
为什么选 SES
- 生态一体:EC2 上发送 SES 邮件,每月前 62,000 封免费
- 成本极低:超出免费额度后每 1,000 封也只要 $0.10
- 送达率高:AWS 背书的 IP 信誉
- SMTP 接口:不需要 SDK,用 nodemailer 直接对接
域名验证三件套
想用自己的域名(dranixj.com)发邮件,需要在 DNS 里证明你是域名的所有者。SES 要求配三样东西:
DKIM(DomainKeys Identified Mail):SES 生成 3 条 CNAME 记录,添加到 DNS 后 SES 可以对发出的邮件签名,收件方能验证邮件没有被篡改。
SPF(Sender Policy Framework):一条 TXT 记录,声明「只有 amazonses.com 的服务器有权代表我的域名发邮件」。因为 Cloudflare 可能已有 SPF 记录,需要合并而不是新建:
v=spf1 include:amazonses.com ~all
DMARC(Domain-based Message Authentication):告诉收件服务器:如果 DKIM 和 SPF 验证都失败了,应该怎么处理(quarantine = 标记为可疑)。
另外还配了自定义 MAIL FROM 域名,让退信通知发到自己的子域名而不是 SES 的默认地址。
Cloudflare 协同:发件走 SES,收件走 Email Routing
DNS 记录全部在 Cloudflare 管理,一共要加:3 条 DKIM CNAME + 1 条 SPF TXT + 1 条 DMARC TXT + MAIL FROM 子域名的 MX 记录。
有意思的是收件方向的处理。网站的发件地址是 [email protected],但用户可能会直接回复这个地址。Cloudflare 的 Email Routing 功能正好可以接收这些回复邮件,转发到我的个人邮箱。
最终形成了一个分工明确的邮件链路:发件走 AWS SES SMTP,收件走 Cloudflare Email Routing。
nodemailer 对接
代码层面的对接很简单,emailService.js 用 nodemailer 通过 SMTP 发送:
// 懒加载 transporter,首次调用时初始化
const transporter = nodemailer.createTransport({
host: config.smtp.host, // email-smtp.ap-northeast-1.amazonaws.com
port: config.smtp.port, // 587
secure: false, // STARTTLS
auth: {
user: config.smtp.user, // SES SMTP 凭证(≠ IAM Access Key)
pass: config.smtp.pass,
},
});
一个容易踩的坑:SES SMTP 的用户名和密码需要在 SES 控制台单独生成,不能直接用 IAM Access Key。
开发体验:console 降级
本地开发时通常没有 SES 环境,也不想每次测试都真的发邮件。解决方案很简单——当 SMTP_HOST 环境变量为空时,emailService.js 不初始化 transporter,而是把验证/重置的完整 URL 直接打印到终端:
[DEV] 验证链接: http://localhost:5173/api/v1/auth/email/verify?token=abc123...
这样本地开发时复制粘贴 URL 到浏览器就能走完全流程,不需要任何外部依赖。另外还提供了邮件模板预览接口(仅开发环境可用),访问 /auth/email/preview/verification 就能看到邮件实际的 HTML 效果。
邮件模板
邮件模板做成了中英双语,纯内联 CSS 样式(不用外部样式表,保证 Gmail、Outlook、Apple Mail 等客户端的兼容性)。布局很简单:灰色背景 + 白色卡片 + 品牌色头部 + CTA 按钮 + 纯文本 URL 降级。
上面这张架构图展示了 SES 在整个系统中的位置——右上角的 AWS SES 通过 SMTP 接收 Express 的发件请求,经过 DKIM 签名后将邮件投递到用户邮箱。
主题改造之旅:从深色到 RX-78-2
主题是这次改造中耗时最长的部分。不是因为技术复杂,而是审美需要反复推翻重来。从 3 月 16 号到 4 月 6 号,整整三周,经历了四个版本才找到满意的方向。
演变时间线
03-16 第一版:深色主题(跟随系统偏好,双主题切换)
04-01 第二版:去掉紫色基调,改为黑白灰配色
04-02 第三版:暗色主题设为默认(不再跟随系统偏好)
04-04 UI 全面调整(分类配色、毛玻璃效果、Bento 布局)
04-05 分类颜色数据驱动化
04-06 第四版:RX-78-2 高达统一主题 — 最终形态
第一版:深色主题(03-16)
最初的想法很简单——给网站加个深色模式,跟随系统偏好自动切换。实现方式是标准的 CSS 变量 + [data-theme="dark"] 选择器覆盖:
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
}
[data-theme='dark'] {
--color-bg: #0f0f17;
--color-text: #e8e8e8;
}
useTheme hook 负责在 localStorage 读写用户偏好,并在首次访问时检测 prefers-color-scheme。Header 上放了一个太阳/月亮切换按钮。
问题很快暴露出来:两套颜色方案意味着每个新组件都要写两遍颜色,维护成本翻倍。而且深色和浅色的视觉效果很难同时做好——顾了这头顾不了那头。
第二版:黑白灰(04-01)
既然配色协调不好,那就简化——去掉所有彩色,只用黑白灰。结果确实干净了,但也无聊了。一个以内容为主的网站,需要一点视觉锚点,纯黑白灰缺乏辨识度。
第三版:暗色默认(04-02)
折中方案:不再跟随系统偏好,暗色直接作为默认。这样至少用户首次访问时不会出现从亮到暗的视觉跳变。但本质问题没解决——还是在维护两套主题。
第四版(最终):RX-78-2 高达统一主题
四月初的某天突然想到——为什么一定要「深色模式」或「浅色模式」?能不能混着来?
灵感来自 RX-78-2 高达的经典配色。这台 1979 年诞生的元祖高达,用蓝、红、黄、白四色创造了极高的辨识度,而它的白色机体搭配深色关节的配色逻辑,正好对应了我想要的效果:
- 内容区域(机体外甲):温暖的灰白色
#f0ede8,阅读舒适 - 框架区域(骨架关节):深色
#161616,Header、Footer、菜单、抽屉都用这个色
这不是「暗色模式」也不是「亮色模式」,而是一种空间层次感——内容在明亮的前景,框架退到深色的背景里。
用 Google Stitch 生成设计系统
配色方案不是凭感觉决定的。在确定 RX-78-2 方向后,我用 Google Stitch 创建了一个名为 "Federation Standard" 的设计系统,把高达的经典四色系统化:
| 角色 | 色值 | 对应部位 |
|---|---|---|
| Primary 蓝 | #005CAF |
联邦蓝,用作链接和强调色 |
| Secondary 红 | #E60012 |
胸部/盾牌红,用作错误和危险提示 |
| Tertiary 黄 | #F9D523 |
头部天线/胸口黄,用作 CTA 按钮 |
| Neutral 黑 | #0D0D0D |
框架深色,用作 Header/Footer |
字体搭配是 Space Grotesk(标题)+ Inter(正文)——Space Grotesk 的几何感和机械感呼应高达主题,Inter 保证正文的可读性。

Stitch 自动生成了 DESIGN.md(设计规范的 Markdown 描述)和 code.html(组件示例代码)。这两个文件直接作为 Claude Code 实现 CSS 时的参考输入——AI 读取设计规范,生成与之一致的 CSS 变量和组件样式。设计工具定义视觉语言,AI 代理执行落地,这个工作流比手写 CSS 高效得多。
CSS 变量系统
最终的 :root 定义了 27 个 CSS 自定义属性,分为两组:
浅色内容变量(用于文章、卡片等内容区域):
:root {
--color-bg: #f0ede8; /* 温暖灰白背景 */
--color-text: #2a2a2a; /* 主要文字 */
--color-muted: #6b6b6b; /* 次要文字 */
--color-accent: #005caf; /* 强调色(RX-78-2 蓝) */
--color-cta: #e7c402; /* CTA 按钮(RX-78-2 黄) */
--color-danger: #e60012; /* 错误提示(RX-78-2 红) */
--color-surface: #e8e4de; /* 卡片/输入框背景 */
}
深色框架变量(用于 Header、Footer、菜单、抽屉):
:root {
--color-surface-lowest: #161616; /* 最深层(Header/Footer) */
--color-text-on-dark: #e8e4df; /* 深色背景上的文字 */
--color-muted-on-dark: #a8a8a8; /* 深色背景上的次要文字 */
--color-border-on-dark: #3a3a3a; /* 深色背景上的边框 */
--color-surface-on-dark: #2a2a2a; /* 深色背景上的交互区域 */
}
关键设计:不使用 prefers-color-scheme 媒体查询,也不用 [data-theme] 选择器。深色效果不是通过「切换主题」实现的,而是在特定组件上直接使用 --color-*-on-dark 系列变量。只有一套变量,没有覆盖,没有切换。
分类颜色数据驱动化
网站有 5 个内容分类,每个分类有自己的主题色:
| 分类 | 颜色 |
|---|---|
| AI | #2F4F4F(暗石灰) |
| Fitness | #005CAF(联邦蓝) |
| TV Game | #E60012(高达红) |
| Hobby | #F9D523(高达黄) |
| Blog | #191970(午夜蓝) |
这些颜色定义在 topics.yaml 配置文件中,构建时写入 JSON,前端组件通过内联 style 注入 --color-cat CSS 变量,再用 var(--color-cat, fallback) 使用。这样新增分类只需要改配置文件,不用碰任何 CSS 或组件代码。
删除的勇气
做了四个版本的主题后,最难的决定其实是删除。
最终版本删除了全部 33 处 [data-theme="dark"] 选择器,撤去了 Header 上的主题切换按钮,useTheme hook 从一个完整的主题管理工具变成了一个 9 行的清理函数:
export function useTheme() {
useEffect(() => {
delete document.documentElement.dataset.theme; // 删除旧的 data-theme 属性
localStorage.removeItem('theme'); // 清除旧的主题偏好
}, []);
}
它唯一的作用是向后兼容——确保之前设置过深色主题偏好的老用户不会因为残留数据出现样式异常。
维护一套主题比维护两套更好。这是减法设计。
不用 Tailwind 的选择
可能有人会问:3800 多行的 global.css,为什么不用 Tailwind?
答案很简单:设计系统完全自控,AI 生成效率足够高。用 Stitch 定义好设计规范,Claude Code 直接生成符合规范的 CSS,不需要 Tailwind 的 utility class 做中间层。纯手写 CSS + CSS 自定义属性,没有 CSS-in-JS,没有 CSS Modules,没有构建时的额外处理。
对于一个个人项目来说,这个方案足够简洁也足够灵活。
AI 协作开发实战:一次完整的 Bug 修复流程
前面三章讲的是「改造了什么」,这一章换个角度——展示怎么改的。
通过一个真实的移动端 Bug 修复案例,完整演示 Claude Code 从接收需求到创建 MR 的全过程。
第一步:自然语言描述需求
开发的起点是一份手写的 Bug 清单,直接在编辑器中用中文记录:

不需要严格的模板,也不用写 JIRA ticket。就是把发现的问题列出来:移动端菜单分割线多余、评论框字号导致 iOS 自动放大、文章切换时点赞收藏状态不同步、Service Worker 缓存了 API 请求导致认证异常……
第二步:AI 分析与计划
把这份需求发给 Claude Code 后,它自动分类整理成结构化的执行计划:

5 个 BUG 修复 + 2 个改善项,每一项都标注了技术本质:哪些是纯 CSS 修改,哪些涉及核心组件重构,哪个需要改 Service Worker 的路径排除规则。
第三步:执行与报告
14 分钟后,7 项修改全部完成。Claude Code 给出了精确到文件和行号的修改报告:

global.css:407:删除.mobile-menu-link的border-bottom- 评论框和注册表单 input 字号改为
1rem,防止 iOS 自动放大 LikeButton.jsx/FavoriteButton.jsx改为受控组件,由父组件管理状态sw.js排除/api/路径缓存pull-hint标题改为-webkit-line-clamp: 2允许两行显示- 登录后隐藏 Header 主题按钮,移到 UserMenu 下拉菜单
- 新建
PhotoFullscreen.jsx组件,支持照片全屏查看和左右滑动
人工审核修改内容,确认无误后进入下一步。
第四步:自动 Commit
一条 "commit" 指令,Claude Code 自动执行完整的 git 工作流:

git status 确认变更文件 → git diff 查看具体改动 → git log 参考最近的 commit 消息风格 → 生成符合项目规范的中文 commit message → git add + git commit。
第五步:自动创建 MR
检测到项目是 GitLab 仓库后,Claude Code 自动创建分支、推送并通过 GitLab API 创建 Merge Request:

MR 描述包含修改概要、详细变更清单、测试计划(带 checkbox)。返回 MR 链接后,全流程结束。
第六步:CI/CD 管道自动触发
MR 创建后 GitLab CI 自动运行,4 阶段管道跑起来:


lint(前端 + 服务端)→ test(audit + 单元测试)→ build → deploy。全部通过后合并进 main。
AI 协作的工作模式
回顾这个案例,人和 AI 的分工很清楚:
- 人的角色:发现问题、描述需求、审核结果、最终决策
- AI 的角色:理解意图、规划执行、修改代码、处理 Git/CI 流程
一个完整的 Bug 修复周期(从记录问题到 MR 创建)大约 20 分钟。同样的 7 个跨文件修改,纯人工开发至少需要 1-2 小时——不是说代码有多难写,而是在不同文件间跳转、确认修改范围、写 commit message、创建 MR 这些流程性工作本身就很耗时。
总结与反思
两周的改造成果
从 3 月下旬到 4 月上旬,大约两周的时间:
- 认证系统:从 2 种 OAuth 扩展到 3 种认证方式(+ 邮箱密码)
- 邮件服务:从无到有搭建 AWS SES + Cloudflare Email Routing
- 主题系统:经历 4 次迭代,最终收敛到 RX-78-2 统一主题
上面这张数据架构图展示了目前系统的全貌:左侧是静态内容流(Markdown → JSON → Nginx),右侧是动态数据模型(User、EmailToken、Like、Favorite、Comment 等),中间通过 slug 桥接。
内容生成方面,三个 Claude Code Skills(文章生成、博客生成、跑步日志)各有分工,最终汇入统一的构建部署管道。
减法哲学
这次改造最大的体会是「做减法」:
- 主题:从维护两套(亮/暗)砍到只维护一套 — 删掉 33 处 dark mode 选择器,维护成本直降
- 邮件:从考虑 SES API SDK 到直接用 SMTP — nodemailer + SMTP 几行代码搞定,够用就好
- 开发环境:SMTP 降级为 console 输出 — 不依赖任何外部服务也能跑完全流程
- 主题管理:从完整的 hook + UI 组件砍到 9 行清理函数 — 功能不再需要就彻底删掉
AI 协作体验
邮箱认证的完整实现(路由、服务、模板、前端组件、数据库迁移)基本由 AI 一次性生成,人工只需要审核和微调。主题改造则相反——方向选择靠人的审美判断,AI 负责快速执行,来回迭代了很多轮。SES 的 DNS 配置记录由 AI 整理成文档,但实际在 Cloudflare 控制台的操作和验证还是得人来。
移动端 Bug 修复的案例(第四章)则展示了一种更极致的协作模式:人只负责发现问题和最终审核,从代码修改到创建 MR 全部由 AI 完成。
下一步
- WebSocket 消息推送:目前的通知(评论回复、@提及)依赖页面刷新或定时轮询,后续考虑加入 WebSocket 实现实时推送
- 评论表情:评论和回复目前只支持纯文本,后续加入表情选择功能
- 用户头像编辑:现在只有 Google/GitHub 登录时获取的第三方头像,邮箱注册的用户没有头像。后续加入头像上传和编辑功能
- 完善发布 Pipeline:目前单篇文章发布和单服务发布的脚本还不够成熟,需要完善自动化流程,让
package-article.sh真正可用 - 接入 Google AdSense:之前尝试过 Adsterra,但体验很差——虽然没有主动设置弹出广告,网页在特定条件下却会莫名弹窗,最终放弃。下一步试试 Google AdSense,看看展示广告的点击率如何
评论