一人公司:邀请制访问控制的设计与实现
不愤不启,不悱不发。举一隅不以三隅反,则不复也。 —— 孔子《论语》
题记:这是「一人技术公司」系列的第六篇。现在 OPC (One Person Company) 的概念很火,我也尝个鲜,看看真要严肃地使用周末来做点一家技术公司才能做起来得事情,到底能做成啥样。这并不意味着我要全职创业,我认为我自己是个一直在路上的创业者,朝九晚五从来不是我的束缚。我想通过这个过程,了解 AI 的边界,以及我自己的边界。
整个系列将记录我从零搭建一套完整技术体系的全过程 —— 从基础设施,到网络与运行时,到核心框架,再到应用服务。这不是一篇教程,而是一份带有思考过程的实战手记。
前五篇我们一路走来:从安全基础设施的构筑,到云架构的选型,到无服务器框架的打造,到私有包仓库的搭建,再到第一个产品应用 Slides 的上线。一套完整的技术体系已经跑起来了。本来这篇就该是收官之作,不过还有一个问题我一直在刻意回避:产品做出来了,谁能用? 让我暂且把总结放入下一篇,这篇继续唠唠技术话题(对不起,我食言了,原定五篇,像五虎上将一样整整齐齐;结果越写越热闹,升级成一根藤上七个娃)。
Slides 目前是「门户大开」的状态:任何有 Google 账号的人都能登进来用。这对于个人原型来说没问题,但如果你认真对待这个产品,哪怕是面向一小群种子用户,你也需要一个门禁。
不是说要搞什么付费墙(那是后面的事),而是最基本的:控制谁能进来。
这就是今天要聊的事:邀请制访问控制。一个听起来不复杂、做起来其实坑不少的系统。
一、为什么需要邀请制?
你可能觉得,一个人的公司,产品刚上线,用户两只手数得过来,搞什么邀请制?
恰恰相反。越是早期,邀请制越有价值。 理由有三:
第一,质量比数量重要一百倍。 早期产品最怕什么?不是没用户,而是来了一堆不对的用户。他们不理解你的产品方向,给出大量噪声反馈,占用你本就稀缺的精力。邀请制让你精挑细选种子用户,每一个进来的人都是你真正想服务的人。
第二,成本控制。 Slides 每生成一张图片都要调 AI 服务,一张图大约 13 美分。门户大开的话,一个好奇的路人随手生成几十张图就能帮你烧掉一杯咖啡钱。百来个这样的路人,月底账单就不好看了。邀请制是最简单粗暴的成本防火墙。
第三,也是最深层的原因:邀请制本身就是产品。 稀缺感制造期待感,被邀请的人比自己注册的人更珍惜产品。这不是营销技巧,这是人性。Gmail 靠邀请制起家,Clubhouse 靠邀请制引爆,这套打法屡试不爽。
但问题来了:我现在只有 Slides 一个应用,将来呢? 如果每个应用都自己搞一套邀请逻辑,认证系统里塞一堆邀请码校验代码,那跟在业务代码里硬编码数据库连接字符串有什么区别?
所以,从第一性原理出发,正确的做法是:把邀请制抽成一个独立服务。
二、Ligand:一个独立的邀请服务
我给这个服务起名 Ligand(配体),延续生物学命名的传统。在分子生物学里,配体(ligand)是与受体结合从而触发信号传导的分子。放在我们的语境里,邀请码就是那个配体,它与用户「结合」,触发「激活」信号,打开应用的大门。
设计原则
Ligand 的核心设计理念是三个字:解耦合。
第一,与应用解耦。 Ligand 不知道 Slides 是什么,也不关心。它只知道「有一个叫 slides 的应用,它有一批邀请码,有一群用户」。明天我做第二个产品,只要在 Ligand 里注册一个新应用,马上就能用同一套邀请体系。
第二,与认证解耦。 Ligand 不做用户登录。它提供 API,由应用的认证流程来调用。用户登录时,应用的认证回调里塞一行 ligand.register_user(email),激活时调一行 ligand.activate_user(email, code)。就这么简单。
第三,与前端解耦。 Ligand 提供的是纯 REST API,前端逻辑(显示邀请码输入框、处理激活响应)由应用自己的前端搞定。但我们在 operon 的 TypeScript 客户端 SDK 里提供了开箱即用的辅助函数,让前端集成也变成几行代码的事。
用户生命周期
Ligand 管理的用户状态是一个简洁的状态机:
Registered(已注册) → 用户通过 OIDC 登录后自动注册,但还不能使用应用的核心功能。这个状态下用户能看到自己的资料页面和邀请码输入框。
Active(已激活) → 用户输入有效邀请码后进入激活态。这是正常使用状态,所有受保护的 API 端点都会放行。
Waitlisted(候补) → 没有邀请码的用户可以选择加入候补名单。管理员可以批量从候补名单中提升用户。
Suspended(已封禁) → 管理员可以封禁用户。被封禁的用户会收到一个明确的 403 响应,前端据此展示封禁页面。
三、邀请码的设计:看似简单的难题
邀请码长什么样?ABCD-EFGH?invite_123456?这个看似无关紧要的决策,背后有不少讲究。
格式设计
邀请码采用可配置的格式模板,默认是 XXXX-XXXX,其中每个 X 会被替换为一个来自精心选择的字符集的随机字符:
/// 32 字符的无歧义字母表
/// 去掉了所有容易混淆的字符:0/O, 1/I/L, 8/B, 5/S, 2/Z
const ALPHABET: &[u8] = b"3479ACDEFGHJKMNPQRTUVWXY";
为什么要专门搞一个「无歧义字母表」?因为邀请码会被口头传播、截图分享、手动输入。如果码里有 0 和 O,用户输错的概率极高,客服压力(哪怕客服就是你自己)会直接拉满。
格式是每个应用可配置的。想要更短的码?XXXX。想要更长更安全的码?XXXX-XXXX-XXXX。这个灵活性是免费送的。
生成策略
码的生成走的是确定性哈希路径:以应用 ID、批次 ID 和序号为输入,通过 SHA-256 生成伪随机字节,再映射到无歧义字母表:
fn generate_code(app_id: &str, batch_id: &str, index: u32, format: &str) -> String {
// 1. 构造确定性种子
let seed = format!("{app_id}:{batch_id}:{index}");
// 2. SHA-256 生成伪随机字节
let hash = sha2::Sha256::digest(seed.as_bytes());
// 3. 映射到无歧义字母表
format.chars().enumerate().map(|(i, c)| {
if c == 'X' {
ALPHABET[hash[i % hash.len()] as usize % ALPHABET.len()] as char
} else {
c // 保留分隔符(如 '-')
}
}).collect()
}
这个设计有个微妙的好处:同一个批次的同一个序号,永远生成相同的码。 这意味着码的生成是幂等的,万一写入数据库的中间步骤出了岔子,重试不会产生不一样的码。