今晚我在后台翻评论,看到两条挨得很近的留言。
同一个邮箱,两个昵称。
第一条叫「阿远」,语气像个老读者,熟门熟路地接着上一篇的话题往下聊;
第二条叫「远哥」,内容也正常,但看起来像另一个人。
可系统不这么认为——头像一样,读者页把两条留言算在同一个人头上,资历标签从「新朋友」变成了「回头客」,回复通知也都进了同一个收件箱。
那一刻我突然意识到:我的博客一直没有一个真正的「评论者身份」。
它只有昵称,和一条随手填的邮箱。
所以动手改动了一些逻辑,往下看;
我尽量写详细,小伙伴们也可以参看。
一、问题不在昵称,在身份
最早做评论功能的时候,我的想法很简单:昵称让人填,邮箱用来收通知,头像用邮箱哈希去 Gravatar 或 QQ 头像里取。够用了。
后来功能一点点加上来:
- 读者页要按人聚合留言
- 排行榜要统计「谁来得最勤」
- 回复邮件要称呼对方
- 评论要显示「新朋友 / 回头客 / 常客」
- 后台要判断这条评论是不是博主自己发的
这些功能都在问同一个问题:这个人是谁?
而我当时的答案,其实只是一条 author 字段。
于是同一邮箱换昵称之后,系统里出现了一堆互相矛盾的现象:
| 位置 | 看到的结果 |
|---|---|
| 评论列表 | 两个不同的昵称,像两个人 |
| 头像 | 同一个头像,像同一个人 |
| 读者页 | 合并成一个读者,名字取最近一条评论 |
| 资历标签 | 按邮箱累计,第二次就是「回头客」 |
| 回复邮件 | 发给同一个邮箱,但称呼的是当时那条评论的昵称 |
更麻烦的是,昵称和邮箱都没有验证。
任何人填一个邮箱,都能评论;任何人换个昵称,都能变成「另一个人」;甚至只要知道我的联系邮箱,把它填进评论框,就能触发一条我当时写的「自动过审」规则。
这不是昵称的问题。
是我从来没有认真定义过:邮箱、昵称、读者,这三者到底是什么关系。
二、我想要的规则
想清楚之后,我给自己定了四条规则:
- 邮箱是身份,昵称是展示名。
同一个邮箱,就是同一个读者。昵称可以变,但身份不变。 - 首次评论自由,后续改名要付一点成本。
第一次来,想叫什么就叫什么;但要换名字,得证明这个邮箱是你的。 - 所有展示口径必须统一。
评论区、读者页、排行榜、邮件称呼,不能各叫各的。 - 身份可信,不等于内容可信。
邮箱验证只能证明「这个邮箱是你的」,不能证明「这条评论没问题」。内容该审核还是要审核。
这四条看起来简单,但真做起来,牵扯到数据库、接口、前端交互、邮件模板,还有后台的审核流程。
三、第一版想法:把昵称锁死
我最开始的方案特别粗暴:同一个邮箱,第一次填什么昵称,以后就固定用什么。
技术上很好实现,查一下邮箱哈希,有记录就把 author 覆盖成历史昵称。
但我很快否掉了。
因为人会变。
有人一开始随手起了个中二昵称,过两年想换成真名;有人换了个马甲,想重新开始;也有人只是打错了一个字,难道要一辈子背着?
如果系统不允许改名,用户就会换一个邮箱。
而换邮箱的代价,是读者页、资历、历史留言全部断掉。这比「一人多名」更糟。
所以我需要的是:允许改名,但要让改名这件事变得可追溯、可验证。
四、最终方案:确认 + 验证
现在的流程是这样的。
1. 用户提交评论时,先做一次身份检测
前端在提交前,会带着邮箱和昵称去问后端一句:
GET /api/comment/identity?email=test@example.com&author=新昵称
后端查这张邮箱对应的身份记录:
- 没有记录:首次评论,直接放行
- 昵称一致:老读者,直接放行
- 昵称不一致:返回冲突,前端弹窗
2. 弹窗只给两个选择
检测到该邮箱之前使用过昵称「阿*」。
- 继续使用原昵称
- 修改为新昵称(需要邮箱验证)
这里有个小细节:曾用名是打码的。
因为如果完整显示,任何人输入一个邮箱地址,就能查到别人曾经用过什么昵称。这算是一种隐私泄露。
3. 继续使用原昵称
用户点「继续使用原昵称」,前端提交一个隐藏字段:
identity_confirmed=1
后端收到后,不信任前端传回来的昵称,而是直接查身份表,把作者名覆盖成当前昵称。
这一点很重要。如果后端只是「相信用户点了确认」,那攻击者改一下请求参数,还是能随便换名字。
4. 修改昵称:邮箱验证码
如果用户选择改名,流程会长一点:
- 点击「发送验证码」
- 后端生成 6 位数字,存哈希,发到该邮箱
- 用户输入验证码
- 验证通过,签发一个短时 token
- 提交评论时带上 token
- 后端校验 token 绑定的邮箱和新昵称,通过后才允许改名
token 是一次性的,5 分钟有效。
改名成功后,系统会做两件事:
- 更新身份表的当前昵称
- 同步更新该邮箱下所有历史评论的昵称
这样读者页、排行榜、评论列表和邮件称呼,才会真正统一。
五、数据表:把「身份」单独拿出来
我没有直接在 comments 表上继续堆字段,而是拆了两张表。
1. 评论者身份表
CREATE TABLE comment_identities (
email_hash TEXT PRIMARY KEY,
email TEXT NOT NULL,
current_author TEXT NOT NULL,
previous_author TEXT DEFAULT '',
verified_at TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
email_hash 是主键,和评论表里的哈希一致。
这样做的原因是:身份是长期的,评论是单次的。
一条评论可以删,可以拒绝,可以改状态,但「这个邮箱曾经叫什么名字」应该留下来。
2. 邮箱验证码表
CREATE TABLE email_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email_hash TEXT NOT NULL,
email TEXT NOT NULL,
code_hash TEXT NOT NULL,
new_author TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at INTEGER NOT NULL,
attempts INTEGER DEFAULT 0,
used_at TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at TEXT NOT NULL
);
验证码只存哈希,不存明文。
每次发送前,会先删掉该邮箱旧的未使用验证码,保证「只有最新一封邮件有效」。
3. 评论表增加两个字段
ALTER TABLE comments ADD COLUMN identity_action TEXT DEFAULT '';
ALTER TABLE comments ADD COLUMN identity_note TEXT DEFAULT '';
identity_action:first/keep/renameidentity_note:给后台看的说明
我特意没有把身份说明塞进 ai_meta。
因为 AI 审核会整段覆盖 ai_meta:
ai_meta = json.dumps({"decision": ..., "reason": ...})
如果身份说明也放在里面,AI 一跑就没了。
不同来源的数据,应该有不同的字段。
六、验证码的细节,比想象中多
验证码看起来很简单,但真正写起来,细节最多。
1. 限频
我设了四道限制:
| 维度 | 限制 |
|---|---|
| 同一邮箱 | 60 秒 1 次 |
| 同一邮箱 | 10 分钟 5 次 |
| 同一邮箱 | 24 小时 10 次 |
| 同一 IP | 1 小时 10 次 |
不是为了防黑客,更多是为了防「手滑」和「骚扰」。
否则任何人输入别人的邮箱,都能反复给人家发验证码。
2. 只允许已有评论记录的邮箱发送
这一点是我后来加的。
如果任何邮箱都能发,那这个接口就成了一个免费的邮件发送器。虽然有限频,但依然不合适。
现在只有「这个邮箱确实在本站评论过,而且新昵称和旧昵称不一样」时,才允许发送。
3. 验证码本身
- 6 位数字
- 10 分钟有效
- 最多尝试 5 次
- 使用后立即作废
- 哈希存储
这些参数不复杂,但少了任何一个,都会留下口子。
4. 邮件模板
验证码邮件没有单独写一套 HTML。
它复用了现有的邮件外壳:
def verify_code_notice(*, blog_name, site, code, ttl_minutes=10):
body = (...)
page = _shell(
headline="验证你的邮箱",
meta_html="",
body=body,
foot_note=...,
)
return text, page
标题、分割线、内容卡、页脚、发件人署名,全部和评论通知一致。
我不想让用户收到一封「看起来不像这个博客发来的」验证码邮件。
七、改名之后,展示要真正统一
改名成功后,执行一条更新:
UPDATE comments
SET author = ?
WHERE email_hash = ?
AND is_owner = 0
AND is_ai = 0
为什么不动态展示时再替换?
因为动态替换意味着:评论列表、读者页、排行榜、邮件、后台,每一处都要记得查身份表。只要漏掉一处,就会出现「评论区叫新名字,读者页叫旧名字」的割裂。
直接更新评论表,最简单,也最不容易出错。
当然,旧昵称没有丢。身份表里留了 previous_author,后台可以看到改名历史。
八、顺手修掉一个更危险的问题
写这套功能的时候,我回头看了一眼原来的审核逻辑,发现了一行很危险的代码:
status = "approved"
评论 1
大家对于,邮箱评论是如何处理的?可以一起探讨!
验证码将发送到