QIAOPANG.COM · 陈天(TYR CHEN)2013–2026 精选技术与随笔文集 · 共 260 篇按时间顺序浏览(2013 → 2026)↗
程序人生 · 陈天文集QiaoPang · Systems, Craft & Agentic Era
时间轴 ↗

一人公司:付费系统的设计与实现

一人公司:付费系统的设计与实现

天下熙熙,皆为利来;天下攘攘,皆为利往。 —— 司马迁《史记》

题记:今天是大年初七,也是新年假期的最后一天。希望大家都度过了一个轻松愉快的马年新春假期!程序君在这里祝大家新春快乐,万事顺遂,马到成功!这是「一人公司」系列的第九篇。在收官之作后,我再次忍不住提笔记录新的征途。前七篇我们搭建了从基础设施到产品应用的完整技术栈,第八篇做了收官总结,聊了商业模式和获客策略。但有一个关键拼图始终缺席:钱怎么收?

邀请制解决了「谁能进来」的问题,现在该解决「进来之后怎么付费」的问题了。一个 SaaS 产品如果不能收费,那它永远只是一个 side project。

注意:本篇使用了 stripe 的服务。我个人认为,stripe 是一家伟大的技术公司,其 API 设计非常考究,测试环境非常完备,是做 SaaS 服务的参考系。如果你做出海服务,或者在海外做互联网服务,涉及到付费,stripe 是迈出第一步的首选。

但使用 stripe 并不意味着付费就可以一帆风顺,尤其是我们这样的,「野心」很大,妄图做若干个业务(或者说可以快速 pivot)的「一人公司」。所以,我们需要请出 operon framework,构建一个独立的计费服务。

一、为什么需要独立的计费服务?

上一篇做 Ligand(邀请服务)时,我已经尝到了「独立服务」的甜头。同样的逻辑放到计费领域,理由更加充分:

第一,计费逻辑比你想的复杂十倍。 你以为付费就是「收钱 → 开通功能」?实际上你要处理:订阅升降级、计费周期轮转、信用额度扣减与退款、团队共享池、Stripe Webhook 的幂等处理、支付失败的优雅降级……把这些逻辑塞进每个应用里,是自找麻烦。

第二,Stripe 集成需要集中管控。 Stripe 的 Webhook 密钥、API Key、Price ID 这些敏感配置,散落在各个应用里是安全隐患。集中到一个服务,只有计费服务跟 Stripe 打交道,应用只跟计费服务打交道。攻击面收窄,安全性直线上升。

第三,第二个产品不用重新造轮子。 今天 Slides 要收费,明天我做第二个产品也要收费。如果计费逻辑在 Slides 里,新产品要么复制粘贴,要么从头写。有了独立计费服务,新产品注册一下就能用。

所以,答案和 Ligand 一样:独立服务,多租户,通过 API 集成。

二、ATP:多租户计费服务

我给这个服务起名 ATP(Adenosine Triphosphate,三磷酸腺苷),延续生物学命名传统。ATP 在细胞里是「能量货币」——几乎所有需要能量的细胞反应都靠 ATP 驱动。放在我们的语境里,信用额度就是应用的「能量货币」,用户每执行一次 AI 生成操作,就消耗一单位 ATP。名副其实。

设计原则

ATP 的核心设计理念是四个字:中心化结算。

第一,面向用户的付费应用永远不直接跟 Stripe 交互。 ATP 是唯一跟 Stripe 对话的服务。应用想创建 Checkout 会话?调 ATP 的 API。应用想查用户余额?调 ATP 的 API。这保证了所有支付逻辑在一个地方,改起来也在一个地方。

第二,信用额度是统一计量单位。 不管应用是卖图片生成、文档翻译还是代码分析,统统转换成「信用额度」。一次图片生成消耗 1 个额度,一次样式生成消耗 1 个额度。额度的成本表(CostTable)每个应用自行配置。统一计量,灵活定价。

第三,多租户但隔离。 每个应用是 ATP 的一个租户,有自己的订阅方案配置、额度包定义和 Stripe 产品映射。应用之间的数据完全隔离,但共享同一套计费引擎。

订阅层级

ATP 定义了三个层级,覆盖了典型 SaaS 产品的定价模型:

Free → Pro → Team
  • Free(免费):每月赠送少量额度,功能受限,不能购买额度包
  • Pro(专业版):每月更多额度,功能全开,可以按需购买额度包
  • Team(团队版):最高额度,团队共享池,支持多人协作

每个层级的具体限制由应用自行定义:

struct TierLimits {
    monthly_credits: u32,           // 每月赠送额度
    max_projects: Option<u32>,      // 最大项目数
    allowed_visibility: Vec<String>,// 允许的可见性设置
    can_purchase_credits: bool,     // 能否购买额度包
    team_features: bool,            // 是否开启团队功能
}

比如 Slides 可能配置 Free 层级每月 20 个额度,Pro 层级每月 500 个,Team 层级每月 2000 个。而另一个应用可能完全不同的数字。同一套引擎,千人千面。

给大家看看成品:

slides

购买 credit:

credits

管理/升级 plan:

plan

三、信用额度系统:看似简单的会计学

信用额度(credits)是 ATP 的核心抽象。它是一个双入口记账系统:CreditBalance 记录当前快照,CreditLedger 记录每笔交易明细。这不是过度设计,这是任何涉及「钱」的系统的基本素养。

三种额度来源

用户的额度来自三个口袋:

一人公司:付费系统的设计与实现 架构图

订阅额度(Subscription Credits) 是月度配额。每个计费周期开始时,Stripe 的 invoice.paid Webhook 触发额度刷新。上个月用剩的不累积——用不完就没了,这是典型的「use it or lose it」模式。

购买额度(Purchased Credits) 是用户额外购买的额度包。这些额度永不过期,是用户的「存款」。

团队池(Team Pool) 是 Team 层级的专属能力。团队管理员购买的额度进入共享池,所有成员都能从中消费。

扣减优先级

当用户执行一次消耗操作(比如生成一张图),ATP 按固定优先级扣减:

订阅额度 → 购买额度 → 团队池

为什么这个顺序?因为订阅额度月末清零,不用白不用;购买额度永不过期,留着最划算;团队池是公共资源,最后再动。 这是对用户最有利的扣减策略。

核心扣减逻辑:

pub async fn debit(
    db: &DynamoClient, email: &str, app_id: &str,
    amount: u32, action: &str, related_id: Option<&str>,
) -> Result<DebitResult, AppError> {
    let user_balance = credit_repo::get_user_balance(db, email, app_id).await?;
    let user_total = user_balance.as_ref().map_or(0, |b| b.total());

    // 1. 用户自己的额度够用,直接扣
    if user_total >= amount as i32 {
        return credit_repo::debit_user_credits(
            db, email, app_id, amount, action, related_id
        ).await;
    }

    // 2. 不够,检查团队池
    let binding = user_repo::get_binding(db, email, app_id).await?;
    let team_id = binding.as_ref()
        .and_then(|b| b.team_id.as_deref())
        .ok_or_else(|| AppError::BadRequest(
            format!("insufficient credits: have {user_total}, need {amount}")
        ))?;

    let team_balance = credit_repo::get_team_balance(db, team_id, app_id).await?;
    let team_available = team_balance.as_ref().map_or(0, |b| b.purchased_credits);

    if user_total + team_available < amount as i32 {
        return Err(AppError::BadRequest(format!(
            "insufficient credits: have {} (user: {}, team: {}), need {}",
            user_total + team_available, user_total, team_available, amount
        )));
    }

    // 3. 先耗尽用户额度,不足部分从团队池补
    let from_user = user_total.max(0) as u32;
    let from_team = amount - from_user;

    // ... 分别扣减,返回 DebitResult
}

DebitResult 会告诉调用方每笔扣减的来源明细:

pub struct DebitResult {
    pub amount: u32,           // 总扣减量
    pub from_subscription: u32,// 来自订阅额度
    pub from_purchased: u32,   // 来自购买额度
    pub from_team_pool: u32,   // 来自团队池
    pub tx_id: String,         // 交易 ID
}
MORE IN AI 与智能体

同专栏延伸阅读

查看「AI 与智能体」全部文章 →

Tokenmaxing 之后,谁在替谁思考?

Agent 可以替人执行很久,人却可能只思考了几秒。一次 AI 限额引发的争执,让我重新审视独立思考、产品判断,以及工作完成之后究竟学到了什么。

New Hope (2025–2026)7,672 字 · 17 分钟