NodePress 支持用户登录了
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。调研下来,无非两条路:
新评论直接 approve:在匿名用户发布评论之后立即通过审核。但这里存在权限问题,Disqus API 多年没有更新,重要问题也没有文档说明,在常规技术上这条路完全走不通,在 2022 年就走不通,今天还是走不通。
不再同步到 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。
TypeScript
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 的身份识别机制,类似如下:
TypeScript
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 表的设计是完全面向人的,有 name、email、url,会绑定社交账户,前端也会展示这些信息。AI 永远不会需要这些,也不应该把它归入用户数据。
所以最终的方案是硬编码 author 信息:AI 的 author 信息在 NodePress 应用的静态配置中配置为固定的 { name: 'AI', email: 'XXX' },AI 在创建评论时直接填入这个信息,以署名访客的身份发布评论,不在 user 表中创建任何记录;前端则通过识别评论中 extras 字段的特殊标记信息来渲染对应的展示样式。
落地实现
设计确定后,实施步骤还算清晰:
- 新建一个
user数据模型,无密码设计,完全通过第三方登录回调来验证用户身份。 - 集成 Google 和 GitHub 的 OAuth 登录,与本地用户记录绑定(或创建新用户)。
Comment、Vote、Feedback以及后续所有允许用户写入数据的表,全部加入user字段,存储用户的ObjectId用于查询关联。
NodePress 端的核心实现,都在 Account 模块。前端部分,则是通过 NodePress 负责登录的 API 拿到一个 Authorize URL,小窗口打开完成登录流程就可以了。
整个登录流程大致如下:
下面记录几个实现过程中值得一提的细节。
保留第三方元信息快照
OAuth 登录成功后,除了存储第三方的唯一 UID 用于身份绑定,我还会把 provider 返回的用户信息作为一份快照存下来,结构如下:
TypeScript
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 验证合法性,验证通过后立即删除,防止重放。
TypeScript
123
export type AuthStatePayload =
| { intent: AuthIntent.Login }
| { intent: AuthIntent.Link; uid: number };
这里的 intent 区分了两种场景:Login 是新用户注册或老用户登录,Link 是已登录用户绑定新的第三方账号。两者共用同一套 OAuth 回调逻辑,只是 Callback 时根据 intent 走不同的分支处理。state 本身不携带任何敏感信息,只是一个指向服务端缓存的随机指针,即便被截获也无法伪造。这也是 GitHub 等主流 OAuth provider 在自己的文档里推荐的标准做法。
无密码系统的兜底
无密码设计的最大风险,是用户被锁在外面:如果唯一绑定的第三方账号出了问题,就再也登不回来了。
NodePress 在代码层面做了一个硬性限制:用户必须至少保留一个 provider 绑定,解绑操作会在服务端校验当前绑定数量,不满足条件时直接拒绝。
TypeScript
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 免费打了四年广告,是时候说再见了。
登录入口在评论区右上角,快来试试吧~
(完)



