惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

云风的 BLOG
云风的 BLOG
C
Cyber Attacks, Cyber Crime and Cyber Security
Recent Announcements
Recent Announcements
爱范儿
爱范儿
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Security Latest
Security Latest
J
Java Code Geeks
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Cisco Talos Blog
Cisco Talos Blog
Apple Machine Learning Research
Apple Machine Learning Research
C
Check Point Blog
T
Threat Research - Cisco Blogs
I
Intezer
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
WordPress大学
WordPress大学
Engineering at Meta
Engineering at Meta
腾讯CDC
Google DeepMind News
Google DeepMind News
Project Zero
Project Zero
T
Tenable Blog
V
Visual Studio Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
Spread Privacy
Spread Privacy
GbyAI
GbyAI
T
Tailwind CSS Blog
P
Palo Alto Networks Blog
Microsoft Security Blog
Microsoft Security Blog
Scott Helme
Scott Helme
Hugging Face - Blog
Hugging Face - Blog
NISL@THU
NISL@THU
Blog — PlanetScale
Blog — PlanetScale
G
GRAHAM CLULEY
K
Kaspersky official blog
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
P
Proofpoint News Feed
S
SegmentFault 最新的问题
P
Proofpoint News Feed
P
Privacy & Cybersecurity Law Blog
The Hacker News
The Hacker News
博客园 - 【当耐特】
Cyberwarzone
Cyberwarzone
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
V
Vulnerabilities – Threatpost
H
Help Net Security
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Last Week in AI
Last Week in AI
博客园 - 叶小钗

Surmon.me

一人有限集团 你就是不敢 创造力是温柔的谎言 人类正在退出人类 AI 代替不了这样的你 脉冲点火背后的架构设计 基于 Cloudflare 生态的 AI Agent 实现 从统计学习到通用智能 2025 投资报告:走慢的路 无依之地 会杀人的菩萨 无我不是共识 文化的积重与偏见 当下即安 科学的尽头是态度 无我不是 Egoless 信仰不因恐惧而存在 世间无解的矛与盾 先别急着做些什么 佛不需要你的皈依 真理的幻觉 两扇大门 造心里的浮屠 自胜者强 逻辑与智慧 真的相 快乐的秘密 只需愿意 是名体验 最深沉的梦 森林里倒下的树 现象与相 庸人自扰 React 与 Vue 的完美融合 开心就好吗 与原生家庭和解 两支毒箭 怎么自净其意 一尘不染 一片白云 让风穿膛而过 喝下去,然后闭嘴 活在当下 当纠结发生时 唯「我」独尊 一场把戏(时间) 一场把戏(二元对立) “我” 和 “我” 谁是众生? 船里没有人 玫瑰不需要说话 没有人能渡任何人 你我为轮,使之不再 当我们在谈论独立时 自我约束就是最好的自我接纳 从善与恶的表象出发 佛法不是心理学 别人眼中的你并不是你 高高山上走 祝你心想事成 祝你的噩梦早日发生 道是无情却有情 裙子只为自己而穿 不要盲目地评估自己 先别急着 “爱自己” 让生命欣然舞动 你无法复制乔布斯 符号不是目的 如果一件事发生了 每当我起心动念时 爱出者爱返 理解不是尊重的基础 在没有解决自己的问题之前 “知足常乐” 不是比较 逻辑是无法成为信仰的 买不到的自由 成为诵经者 伤口听不懂大道理 是立场让你不自由 - Unfreedom 天雨大不润无根之草 做自己的神 当心诱惑本身 牛洞冥思录 修罗启示录 让大脑自举 我更喜欢叫它无梦寺 存在主义也许不是解药 出南洋记 2022 的所有答复 没意义的表达 思考的记录 故宫暂行之幻想 极端的比较 解构的终极 舍利子是如何炼成的 心要野 佛教的偏见 文艺青年 我是动物 知行合一
NodePress 支持用户登录了
Surmon · 2026-03-18 · via Surmon.me

2026 年 2 月初,我在 NodePress 中设计并上线了全新的独立用户系统。起因是一个看起来很小、却怎么也绕不过去的 BUG。

从多说到 Disqus,再到弃坑

要说清楚这件事,得从 2017 年讲起。

那时候国内有一款类似 Disqus 的社会化评论产品叫「多说」,Surmon.me 最早的评论能力就是通过多说接入的。直到多说关闭,我才实现了第一版自建的 署名评论系统 ,只有署名访客可以留下评论;但,事实上名字和邮箱都可以伪造,所以这套方案本质上不具备真正的身份意义。

2022 年,我 接入了 Disqus ,由于 Disqus 在国内无法访问,这中间还实现了一套复杂的代理流程。简单来说,就是把 Disqus 当成一个外挂的身份系统:用户可以通过登录 Disqus 来「登录」到 Surmon.me,然后进行评论。这在当时算是一个相对完整可用的方案。

但其实,这套方案一直有些鸡肋。展示评论时我优先拿的都是本地数据,用户在站内评论会在本地数据库和 Disqus API 各存一份,Disqus 除了一个没什么人用的登录按钮,几乎没有太大存在感。重度强迫症的我一直想把它移除,只是没找到合适的时机。

这个时机,终于来了。

AI 打破寂静

也是上个月,我在 NodePress 中实现了 AI 评论回复 能力。按照当时的评论流转逻辑,AI 产生的评论数据必须先在 Disqus 存一份(发布),成功之后再在本地数据库存一份。但 AI 是以「署名访客」身份进行评论的,这种身份对于 Disqus 来说属于「匿名用户」。

问题来了:Disqus 对于匿名用户产生的评论,无论如何都无法做到直接发布,它总会进入 pending 状态。也就是说,AI 回复确实产生了,但它会卡在 Disqus 的审核后台,用户永远看不到。

为了解决这个问题,我又重新翻开当年的技术笔记,梳理这层官方文档语焉不详、社区问答没有回应的世纪大 BUG。调研下来,无非两条路:

  1. 新评论直接 approve:在匿名用户发布评论之后立即通过审核。但这里存在权限问题,Disqus API 多年没有更新,重要问题也没有文档说明,在常规技术上这条路完全走不通,在 2022 年就走不通,今天还是走不通。

  2. 不再同步到 Disqus:干脆让机器人的评论只存本地,不走 Disqus。但这样会带来另一个更严重的问题:机器人的评论在 Disqus 那边没有 ID,用户来回复这条评论时,评论的 extras 上所挂载的 disqus_post_id 元信息就是 null,永远无法在 Disqus 那边成功关联,以后这个功能也没有任何扩展空间。

两条路都走不通,只能下定决心:完全废弃 Disqus

设计新的用户系统

废弃 Disqus 之后,评论的身份问题需要从根上重新解决。

我的思路是:集成 Google 和 GitHub 的 OAuth 登录,建立一张独立的 user 数据表,用户通过第三方 OAuth 登录回调来验证身份,系统本身不设密码。 只要第三方不倒闭,用户系统永远稳定可用,同时也避免了存储密码的复杂性和数据泄露的风险。

但在开始动手之前,还有一个设计上的核心问题需要梳理清楚:NodePress 的「用户」到底是什么含义?管理员属于用户吗?权限如何划分?

NodePress 最初只是设计为单管理员系统,管理员通过后台发布和管理数据,前端的所有写操作只有署名访客这一种角色。引入新的用户系统时,我希望维持原有这套系统的简单,不让 user 成为核心业务。即便将来整个 user 表被替换成其他方案,核心数据也应该能正常工作。答案也由此清晰:Admin 和 User 是完全独立的两条线,管理员不属于用户,不存入 user 表。

所以,即便新增了用户系统,原有的写操作(如评论、点赞)在消费 user 数据的地方,都会继续保留原有的署名访客字段,并在原有结构上额外存一个 user 字段(实际存储的是 user.ObjectId)。这样的存储方式,类似于同时存一份「作者」快照 + 用户 ID。

        
        

123456789

interface Comment { // ... user: Ref<User> | null; author_name: string; author_email: string | null; author_website: string | null; author_type: CommentAuthorType; // ... }

用户层级

整体梳理下来,在新设计中,NodePress 里存在三种角色:

  • Guest(署名访客):无需任何登录,填写名字和邮箱即可评论。访客不构成用户实体,不存入 user 表。
  • User(注册用户):通过 Google 或 GitHub OAuth 登录的前台用户,存入 user 表,拥有稳定的 ID,可以查询自己的历史评论、点赞等数据,也可以一键导出或删除账户。
  • Admin(管理员):通过后台单入口登录,只需输入密码获得 Token 即可进入管理后台。不考虑多管理员的情况,不与任何社交账户绑定,不存入 user 表。

由于这种特殊的业务需要并不符合传统的 RBAC 权限模型,所以我在 NodePress 中实现了一个特殊的 IdentityGuard 的身份识别机制,类似如下:

        
        

123456789101112131415161718192021222324252627

export enum IdentityRole { Guest = "guest", Admin = "admin", User = "user", } export class Identity { public readonly role: IdentityRole; public readonly token: string | null; public readonly payload: AuthPayload | null; constructor(options: IdentityOptions) { this.role = options.role; this.token = options.token ?? null; this.payload = options.payload ?? null; } get isGuest() { return this.role === IdentityRole.Guest; } get isAdmin() { return this.role === IdentityRole.Admin; } get isUser() { return this.role === IdentityRole.User; } }

除了注册用户和管理员的区分,还有两个特殊的用户身份需要处理:博主(Moderator)AI 机器人

用户身份特征

整个网站只会有一个博主(大部分时候也就是管理员本人),这个身份在几个地方会被消费:

  • 前端用来展示特定的身份标记。
  • AI 评论机器人用来判断是否跳过自动回复。
  • 发送邮件通知时用来确认昵称。

这份身份特征与数据权限没有任何关系,只是作为业务语义上的一种区分。

一开始考虑过把博主的 user_id 硬编码到配置里,或者约定 user_id = 0 就是管理员。但这两种方式都过于 hack,不如直接在 user 数据表里加一个 role 字段,存储普通用户和博主的区别。把身份特征存在数据里,灵活性更好,也便于日后扩展(比如评论区用户名右侧的 Patron 徽章,就是通过这里实现的)。

所谓的「博主」或「特别用户」,在数据层面都只是 user 表里不同的 role,本质上都是注册用户。

AI 身份特征

AI 机器人的身份消费场景和用户类似,主要用于前端展示身份标记,以及 AI 在创建评论时填入评论者信息。

user 表的设计是完全面向人的,有 nameemailurl,会绑定社交账户,前端也会展示这些信息。AI 永远不会需要这些,也不应该把它归入用户数据。

所以最终的方案是硬编码 author 信息:AI 的 author 信息在 NodePress 应用的静态配置中配置为固定的 { name: 'AI', email: 'XXX' },AI 在创建评论时直接填入这个信息,以署名访客的身份发布评论,不在 user 表中创建任何记录;前端则通过识别评论中 extras 字段的特殊标记信息来渲染对应的展示样式。

落地实现

设计确定后,实施步骤还算清晰:

  1. 新建一个 user 数据模型,无密码设计,完全通过第三方登录回调来验证用户身份。
  2. 集成 Google 和 GitHub 的 OAuth 登录,与本地用户记录绑定(或创建新用户)。
  3. CommentVoteFeedback 以及后续所有允许用户写入数据的表,全部加入 user 字段,存储用户的 ObjectId 用于查询关联。

NodePress 端的核心实现,都在 Account 模块。前端部分,则是通过 NodePress 负责登录的 API 拿到一个 Authorize URL,小窗口打开完成登录流程就可以了。

整个登录流程大致如下:

下面记录几个实现过程中值得一提的细节。

保留第三方元信息快照

OAuth 登录成功后,除了存储第三方的唯一 UID 用于身份绑定,我还会把 provider 返回的用户信息作为一份快照存下来,结构如下:

        
        

12345678910

export class UserIdentity { provider: UserIdentityProvider; // 'github' | 'google' uid: string; // 第三方唯一标识 email: string | null; username: string | null; display_name: string | null; avatar_url: string | null; profile_url: string | null; linked_at: Date; }

这份快照在首次绑定时写入,之后不再覆盖更新。这样做有两个好处:一是前端展示用户信息时不需要实时回源到第三方;二是即便用户日后修改了 GitHub 用户名或头像,历史记录里的绑定信息依然可查。

OAuth state 的防 CSRF 设计

标准的 OAuth 流程里,state 参数的首要作用是防止 CSRF 攻击。但在实际实现中,state 还可以承载更多语义。

NodePress 里的做法是:发起 OAuth 请求前,在服务端生成一个随机 UUID 作为 state,同时把当前操作的意图(intent)存入缓存,以 state 作为 key,有效期 5 分钟。Callback 回来时,用 state 取出缓存中的 payload 验证合法性,验证通过后立即删除,防止重放。

        
        

123

export type AuthStatePayload = | { intent: AuthIntent.Login } | { intent: AuthIntent.Link; uid: number };

这里的 intent 区分了两种场景:Login 是新用户注册或老用户登录,Link 是已登录用户绑定新的第三方账号。两者共用同一套 OAuth 回调逻辑,只是 Callback 时根据 intent 走不同的分支处理。state 本身不携带任何敏感信息,只是一个指向服务端缓存的随机指针,即便被截获也无法伪造。这也是 GitHub 等主流 OAuth provider 在自己的文档里推荐的标准做法。

无密码系统的兜底

无密码设计的最大风险,是用户被锁在外面:如果唯一绑定的第三方账号出了问题,就再也登不回来了。

NodePress 在代码层面做了一个硬性限制:用户必须至少保留一个 provider 绑定,解绑操作会在服务端校验当前绑定数量,不满足条件时直接拒绝。

        
        

1234567

public async removeIdentity(userId: number, provider: UserIdentityProvider) { const targetUser = await this.userService.findOne(userId) if (targetUser.identities.length <= 1) { throw new BadRequestException('At least one authentication method is required.') } return await this.userService.pullIdentity(userId, provider) }

这样,用户可以同时绑定 Google 和 GitHub 两个 provider,任意一个出现问题都还有另一条路。

弹窗登录与关闭检测

前端的登录体验采用小窗口弹出的方式,用户在弹窗里完成 OAuth 授权,主窗口监听弹窗关闭后刷新登录状态。

实现上用到了两个不算「标准」的手法:用 popupWindow.closed 轮询检测弹窗是否已被用户手动关闭,以及在弹窗打开的瞬间用 popup.document.write(LOADING_HTML) 写入一个过渡页,避免跳转前的白屏闪烁。

有意思的是,翻看 TikTok 官网的登录实现,用的也是完全相同的两个手法。这两种做法都称不上是 W3C 标准里推荐的方式,但在实际的跨窗口登录场景中,它们是目前最简单、兼容性最好的解法,算是一种业界默认的工程妥协。

新用户注册通知

每当有新用户首次通过 OAuth 完成注册,NodePress 会自动向管理员发送一封邮件通知,包含新用户的基本信息、注册来源(Google / GitHub)、用户 ID 以及请求 IP 地址。成本极低,但对于一个个人博客来说,能第一时间知道「又来了一个真实用户」,还是挺让人兴奋的。


这次改动的规模不算大,代码实现也并不多,但 OAuth 的联调测试确实很挑战人的耐心。不过,自建系统确实解决了非常实在的问题:AI 评论再也不会卡在审核队列里,每一条评论背后都可以追溯到一个真实的用户身份,系统整体也比之前干净了很多。

这也再一次印证了我一贯的个人准则:如果一个系统追求稳定、可控、轻盈,最终总是要走向「自建」。

最终在前端实现了简易的用户管理面板:

在管理后台也可以轻松地看到用户的各种信息:

AI 也可以正常回复评论了:

最后,与其说是 Disqus 陪伴了 Surmon.me 四年,倒不如说是 Surmon.me 给 Disqus 免费打了四年广告,是时候说再见了。

登录入口在评论区右上角,快来试试吧~

(完)