欢迎光临
我们一直在努力

freehire 三档订阅设计解析:从 `pro_until` 到 Ultra,以及把 auto-apply 变成计量功能

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:
https://gitcode.com/GitHub_Trending/fr/freehire

点击查看 免费下载

本文围绕 freehire 的 add-ultra-plan 变更设计(design.md),拆解一个看似简单的产品需求——"再加一个更贵的档位"——为什么会变成一次数据建模、计费接缝和计量语义的连锁重构。读完之后,你会理解 freehire 如何用"来源列 + 生成列"表达三档订阅、如何让支付提供方在一次读取中回答所有档位、为什么 auto-apply 是唯一一个一上线就强制执行的计量功能,以及如何在不发版的情况下回滚这一切。


一、起点:一个问题被拆成两个

设计文档的 Context 一节点出了两件事,而它们本质上是同一个问题:

  • 代码只能表达两档订阅。 plan.TierOf 读取 users.pro_until 这个时间戳,返回 pro 或 free。系统里没有任何地方记录"这个账号买的是什么",只记录"它还能用到什么时候"。因此第三个档位不是配置修改,而是建模修改。
  • Ultra 想卖的那个功能根本没有被计量。 PostJobAutoApply 允许任何 Pro 用户使用且不做任何计数,于是产品里最重的一个动作——一个无头浏览器替你填写并提交一份真实求职申请——被以 5 美元的固定价格平摊售卖。
  • 两件事互为因果:只有先把动作计量起来,才能在它上面定价一个新档位。

    设计文档还给出了时间窗口上的判断:生产环境当时只有两个付费订阅者、auto-apply 队列在整个历史中只有两条记录。此刻给 Pro 的 auto-apply 加天花板,是"和两个人沟通";六个月后再做,就是"一次事故"。

    为什么迁移 0135 是模板

    0135_pro_until_sources.sql 把 pro_until 拆成三个来源列,每个来源恰好一个写入者,再由 schema 推导出有效值:

    列写入者语义
    pro_until_stripe Stripe 同步 Web 端订阅
    pro_until_revenuecat RevenueCat 同步 App Store / Google Play 订阅
    pro_until_granted 手工授予(后续由邀请功能写) 赠送/客服补偿,任何 provider 都不得清除
    pro_until 无人可写(GENERATED ALWAYS AS (GREATEST(…)) STORED) 三者中最远的那个时刻

    这次拆分存在的理由,在 migrations/0135_pro_until_sources.sql 的注释里说得很直接:一个 provider 报告"没有订阅",说的是"我不赋予任何东西",而不是"这个账号不是 Pro"。如果两个 provider 和一次人工授予共用一列,下一次 Stripe 同步就会把 Apple 卖出去的时间覆盖掉——而且是静默地、朝着"剥夺用户已付费权益"的方向。

    Ultra 档完整复用这套形状,见 migrations/0141_ultra_until_sources.sql。


    二、目标与非目标

    设计文档明确划定了边界,这些边界本身是理解实现的前提。

    目标

    • 一个系统能够表达、解析并售卖的第三档订阅。
    • auto-apply 被计量,使更高档位可以据此定价。
    • 有一处地方说明每个档位在每个功能上每天允许多少。
    • 源码中没有任何地方知道某个 price id 的值。

    非目标

    • App Store / Google Play 上的 Ultra 产品。列被创建了,产品没有。
    • 自建换档流程。支付提供方的门户已经能改订阅;billing/AGENTS.md 出于同样的理由拒绝自建取消流程——那只是又多了一个可能和"钱到底发生了什么"相矛盾的东西。
    • 按比例计费、退款、升级抵扣。这些归提供方所有。
    • 计量其他任何东西。已有五个功能保持它们的数字和影子模式(shadow mode)。

    三、核心决策逐条拆解

    3.1 Ultra 拥有自己的权益列,而不是一个 tier 列

    被采纳的方案是照抄 0135:

    ALTER TABLE users
    ADD COLUMN ultra_until_stripe timestamp with time zone,
    ADD COLUMN ultra_until_revenuecat timestamp with time zone,
    ADD COLUMN ultra_until_granted timestamp with time zone;

    ALTER TABLE users ADD COLUMN ultra_until timestamp with time zone
    GENERATED ALWAYS AS (GREATEST(ultra_until_stripe, ultra_until_revenuecat, ultra_until_granted)) STORED;

    被否决的替代方案是单一 users.plan_tier 列。否决理由正是 0135 当年为之而写的那一条:单列只有一个写入者,那么两个 provider 加一次客服授予就必须就"谁最后写"达成一致;一个报告"没有 Ultra 订阅"的 provider,要么清掉别的来源仍在赋予的档位,要么无法清掉自己赋予的。按来源拆分,才让"我不赋予任何东西"这句话变得可表达。

    还有一个常被忽略的好处:答案的形状仍然是日期。下游的一切——对账器(reconciler)的临近到期扫描、每次计量动作时的计划读取——都已经以"时刻"思考;如果改成用名字表达的档位,就需要在旁边再造一套机制。

    注意 GENERATED 而不是触发器的选择。迁移注释说得很明确:触发器的重点错位——它把规则留在 schema 之外,下一个读 \\d users 的人只看到一个普通的可空列,完全看不出给它赋值是没有意义的。生成列让错误的写入不可能(Postgres 报 428C9),而不只是"不推荐"。

    GREATEST 会忽略 NULL,只有当所有来源都是 NULL 时才返回 NULL——这正好就是"没有任何来源赋予任何东西"。

    3.2 provider 接缝按档位携带一个值

    原来的 reach 返回一个 time.Time,store 写一列。两者都拓宽为携带一个小的按档位结构:

    // billing
    type entitlement struct {
    Pro time.Time
    Ultra time.Time
    }

    实现中这个结构还包含了 API 产品的两档(见下文 3.6),位于 internal/identity/billing/entitlement.go。

    一次 reach 返回两者,而不是调用两次。 这是整个设计里最容易被"优化"掉的一条。billing/AGENTS.md 的规则是:webhook 只是信号,provider 的状态要被整体重读。两次读取可能落在一次变更的两侧,从而互相矛盾——这正是这个包里"整体推导,绝不增量调整"规则要防的东西。一次读取、两个答案、两列一起写。

    一个 provider 要写它拥有的每个档位列,包括它报告不出任何东西的那些。 RevenueCat 今天不卖 Ultra 并返回零,但它仍然写这一列。理由写在 revenuecat.go 的注释里:只写"自己有话可说"的列的 provider,永远无法收回它曾经说过的话——而那就是取消路径。

    接口签名在 internal/identity/billing/provider.go:

    reach(ctx context.Context, userID int64) (entitlement, error)

    store(ctx context.Context, userID int64, ent entitlement) error

    dueSoon(ctx context.Context, from, to time.Time, max int32) ([]int64, error)

    dueSoon 的谓词必须落在 provider 自己的来源列上,绝不能落在派生的 pro_until / ultra_until 上。原因写在注释里:派生列是三个来源中最远的那个,一个订阅者的另一个来源如果伸得更远,他就会落在窗口之外,永不被重新检查——而"丢失的续订"正是这条通道存在的理由。任务清单 3.6 也明确要求:dueSoon 必须能在任一权益临近到期时命中账号,否则一个正在失效的 Ultra 永远不会被对账。

    3.3 一次购买赋予哪个档位,由价格列表决定

    entitlement.go 里的 proUntilFrom(sub, proPrices) 早就做了这件事:把订阅者的订阅过滤到"价格出现在配置列表里"的那些。加一个列表——STRIPE_ULTRA_PRICE_IDS——就是全部。

    实现把同一套过滤复用四次,而不是复制四份:

    func entitlementFrom(sub subscriber, cfg Config) entitlement {
    return entitlement{
    Pro: proUntilFrom(sub, cfg.Prices),
    Ultra: proUntilFrom(sub, cfg.UltraPrices),
    API: proUntilFrom(sub, cfg.APIPrices),
    Data: proUntilFrom(sub, cfg.APIDataPrices),
    }
    }

    配置读取在 internal/identity/billing/config.go:

    Prices: priceList(os.Getenv("STRIPE_PRICE_IDS")),
    UltraPrices: priceList(os.Getenv("STRIPE_ULTRA_PRICE_IDS")),
    APIPrices: priceList(os.Getenv("STRIPE_API_PRICE_IDS")),
    APIDataPrices: priceList(os.Getenv("STRIPE_API_DATA_PRICE_IDS")),

    priceList 对未设置的值返回空列表而不是猜测的默认值。而 subscription.entitles 对空列表匹配不到任何东西——于是一个没有配置 Ultra 价格的部署,永远不会把任何人解析成 ultra,pro 和 free 的行为与今天逐字节相同。

    被否决的替代方案:把档位写在 Stripe price 的 metadata 上。理由是这个仓库里没有任何东西能测试它,而且一次误点就能静默地把所有人升级。

    一个有意留下的边角:同一个 price 如果同时出现在两个列表里,会让一次订阅同时赋予两档。代码注释称之为配置错误而非需要编码的情形,并且它失败得很安全——账号解析为 ultra,也就是两者中更好的那个。

    3.4 featureConfig 重塑为"每档一个额度"

    这是设计文档里最工程化、也最容易被低估的一节。

    原来的结构持有 freeDaily 加 proFairUse,因为 pro 处处无限、free 是唯一有真实数字的档位。Ultra 打破了它:pro 的 auto-apply 是每天硬性 3 次,不是"无限 + 公平使用"。在 proFairUse 旁边塞一个 proDaily 会留下两个字段,而它们的交互没人能从调用点读出来。

    于是配置改成"每档一个 Allowance",公平使用数字附着在额度为无限的档位上:

    type tierAllowance struct {
    daily int
    unlimited bool
    }

    type featureConfig struct {
    pro tierAllowance
    ultra tierAllowance
    enforce bool
    }

    Allowance(tier, feature) 从"对某个档位做 if 判断"变成一次查表(internal/ai/plan/plan.go)。零值意味着什么都不允许——这正是"未配置的档位"应有的行为,也是任务 2.2 要求断言的点。

    decide.go 的公平使用分支同样被泛化:从 c.ProFairUse(f) 变成读取额度上的 Unlimited 标志:

    if allowance.Unlimited && used >= allowance.Limit {
    d.FairUse = true
    return d
    }

    注释点出了这个改写最大的收益:decide 现在完全不提任何档位的名字。它从额度上读 Unlimited,然后把守卫应用到任何带有它的档位——第三档在这里不需要任何分支。

    出厂数字(DefaultConfig,internal/ai/plan/plan.go)如下:

    功能feature 标识proultra形状
    CV 改写 tailor 40 120 无限 + 公平使用守卫
    匹配分析 match 60 180 无限 + 公平使用守卫
    助手对话 assistant 200 600 无限 + 公平使用守卫
    语音听写 dictation 200 600 无限 + 公平使用守卫
    求职信 cover-letter 60 180 无限 + 公平使用守卫
    自动投递 auto-apply 3 100(无限) pro 是真实日上限,ultra 是宽松守卫

    free 在表中没有列,因为它根本不是一个数字:featureConfig 没有 free 字段,decide 在读取任何额度之前就用 tier.Paid() 拒绝了未付费档位。这是实现阶段相对任务清单的一处收敛,plan.go 与 internal/ai/plan/AGENTS.md 都记录了理由:一个只能用来放松产品不变量的环境变量,早晚会被运维误拉。

    design.md 有一处 "Amended during implementation" 值得单独记住:最初计划同时保留 PLAN_FAIR_USE_<F> 和 PLAN_PRO_DAILY_<F>,但两者写的是同一个字段——某档的数字在它有上限时就是上限,在它没有上限时就是公平使用守卫。保留两个名字等于给一个字段起两个名,与同一段文档别处的论点自相矛盾。而且从来没人在生产主机上设置过任何 PLAN_* 变量,所以旧名字被删除而不是携带。

    3.5 拒绝时向任何不在最高档的人提供升级入口

    这是实现期间追加的决定,而且它覆盖了全部六个功能,不只是 auto-apply。

    原来 402 只给 free 档提供升级链接——在 pro 是产品线顶端时这是对的:对一个订阅者没什么可卖的。Ultra 出现在上面之后,这条规则恰好对最值得卖的那个人(因为 auto-apply 被拒的 pro 订阅者)隐瞒了链接,而这次拒绝正是它最有价值的一刻。

    实现把它写成"不是最高档",而不是"低于 Ultra 的档位列表",这样将来加第四档时,不会静默地停止向被它取代的那一档提供升级(internal/api/handler/plan_view.go):

    func refuse(c *fiber.Ctx, d plan.Decision) error {
    a := viewDecision(d)
    return write402(c, a, refusalMessage(d.Feature, d.FairUse, planIncludes(a)),
    !d.FairUse && hasSomethingToBuy(d.Tier))
    }

    func hasSomethingToBuy(tier plan.Tier) bool { return tier != plan.TierUltra }

    拒绝体长这样(结构来自 internal/api/handler/auto_apply_allowance_integration_test.go):

    {
    "error": "You've used today's allowance for this.",
    "allowance": {
    "feature": "auto-apply",
    "used": 3,
    "limit": 3,
    "unlimited": false,
    "enforced": true,
    "resets_at": "2026-10-23T00:00:00Z"
    },
    "upgrade_url": "/my/plan"
    }

    公平使用守卫触发时不会带上 upgrade_url:在一次基础设施守卫拦住某人时推销更大的套餐,读起来像敲诈;而对已经买全的人,也没有什么可卖。

    3.6 账户计划接口读取调用者实际所在的档位

    GET /me/plan 原来无条件地从 pro 列填充 pro_until 和 pro_source,于是 ultra 订阅者会收到 plan: "ultra" 却没有这两个字段。

    pro_source 是行为性字段,不是信息性字段:它存在的目的是让客户端不要向已经在 Stripe 付费的人展示应用内购买。一个付费账号上空的 pro_source,正是这个字段要防止的重复扣款——而且是由本该防止它的端点产生的。

    修复方式是按实际档位选择来源列(internal/api/handler/me_plan.go):

    derived, stripe, revenuecat, granted := sources.ProUntil, sources.ProUntilStripe,
    sources.ProUntilRevenuecat, sources.ProUntilGranted
    if tier == plan.TierUltra {
    derived, stripe, revenuecat, granted = sources.UltraUntil, sources.UltraUntilStripe,
    sources.UltraUntilRevenuecat, sources.UltraUntilGranted
    }

    字段保留 pro_ 拼写,因为客户端在读它;它一直以来的含义就是"你正在付费的计划什么时候到期,你是在哪里买的"。

    读取来源列本身是一次额外的行读取,而不是一次 provider 调用——这个端点必须在 provider 不可用时仍能回答。而且读取失败会让整个响应失败:只记日志然后照常返回 200,会得到"说你是 pro 却不带 pro_source"的答案,客户端据此就会向已付费用户再卖一次。

    3.7 档位解析:Ultra > Pro > Free

    func TierOf(proUntil, ultraUntil, now time.Time) Tier {
    if ultraUntil.After(now) {
    return TierUltra
    }
    if proUntil.After(now) {
    return TierPro
    }
    return TierFree
    }

    三个细节都值得记住:

  • Ultra 先检查,而且即使 pro 的权益伸得更远,Ultra 也赢。 两个订阅可以同时生效——provider 的门户在升级过程中让它变得很平常——而"客户花更多钱时计划解析可能下降"是个会以最糟糕方式被人注意到的 bug。
  • 比较是严格的,权益在到期那一刻就结束。任何宽限期都是产品决策,应该放在有人看得见的地方。
  • 每次计量动作都会调用它,所以签名变化时由编译器找出所有调用点;真正的风险是某个调用者把同一个时刻传了两次,静默地把所有人解析成 ultra。任务 2.1 要求对边界做表驱动测试,internal/ai/plan/tier_test.go 里覆盖了"都没买""只有 pro""只有 ultra""两者都活""两者都活但 pro 更远""ultra 刚过期""都过期""ultra 恰好是现在"八种情形。
  • 读取侧同样是"一行、一次查询"(internal/ai/plan/store.go):

    func (s *Store) Tier(ctx context.Context, userID int64) (Tier, error) {
    untils, err := s.q.GetPlanUntils(ctx, userID)
    …
    return TierOf(untils.ProUntil.Time, untils.UltraUntil.Time, s.now()), nil
    }

    为什么强调"一行而不是每档一次查询":两次读取可能落在一次同步的两侧,而"从两个不同时刻解析出的档位"正是某人瞬间变成"哪一档都不是"的方式。

    另外,Tier 上有一个 Paid() 方法:

    func (t Tier) Paid() bool { return t == TierPro || t == TierUltra }

    它存在的原因写在注释里:一条含义为"有人在付费"的规则,绝不能写成 == TierPro。那个形状在 CV 改写的轮次上限上第一次被写出来时,恰好把 Ultra 排除在外——买更贵的计划反而让会话被提前截断。

    3.8 auto-apply 的扣费在队列入库之前,以职位为键

    Consume(user, feature, ref) 按 ref 幂等。把 ref 设为职位的 id,顺序问题就自己解决了:

    • 双击的按钮只扣一次——第二次调用读作"已扣费"。
    • 数据库错误后的重试也只扣一次,理由相同。
    • 超额度请求在任何东西入队之前就被拒绝。

    反过来,在队列入库之后扣费就必须用队列行自己的 id 作为引用,而重复请求永远拿不到那个 id——于是第二次点击会再扣一次。在之前扣、以职位为键,是从键本身获得幂等,而不是与它对抗;这和 sessionRef 为改写轮次所用的技巧是同一个。

    实现见 internal/api/handler/auto_apply_enqueue.go:

    func (h *assistantHandlers) chargeAutoApply(c *fiber.Ctx, userID, jobID int64) (bool, error) {
    if h.plans == nil {
    return false, fiber.NewError(fiber.StatusServiceUnavailable, "auto-apply is not available")
    }

    ref := "job:" + strconv.FormatInt(jobID, 10)
    d, err := h.plans.Consume(c.Context(), userID, plan.FeatureAutoApply, ref)
    switch {
    case err == nil:
    return false, nil
    case isRefusal(err):
    return true, refuse(c, d)
    default:
    log.Printf("plan: charging an auto-apply for user %d on job %d: %v", userID, jobID, err)
    return true, fiber.NewError(fiber.StatusServiceUnavailable, "auto-apply is not available")
    }
    }

    那些与额度无关的检查——平台不支持、没有 CV、已经投递过——全部排在扣费之前,所以因为这些原因被拒绝的请求不花费任何额度。

    3.9 这个功能从第一次部署起就强制执行

    其他每个计量功能都以 enforce: false 上线,先在影子模式下观察。那条惯例存在的原因,是保护人们免受"没人用真实行为验证过的天花板"。

    它在这里不能适用:一个只计数不拒绝的 pro 天花板,会让 Ultra 什么都卖不出去;在有了天花板之前发布档位,等于卖一个还不存在差异的计划。

    这也没有看上去那么破例:PostJobAutoApply 今天就已经对整个 free 档用 402 拒绝。额度只是取代了那道门,而不是新增一种拒绝;而 plan/AGENTS.md 要求的预检查惯用法(用 Refuses,绝不用 Exhausted)让每个界面都对它保持诚实。


    四、请求链路走读:PostJobAutoApply 的闸门顺序

    设计里的每一条顺序约束,最终都落在这一条路由上。方法注释本身就是一份规范(internal/api/handler/auto_apply_enqueue.go),闸门依次是:

  • 解析用户(requireUserID),失败即返回。
  • 按 slug 取职位;pgx.ErrNoRows 映射为 404。
  • ATS 平台检查:autoApplyEnqueueSources 只包含 greenhouse / ashby / workable / lever / recruitee,其余返回 400。注释说明只有 Greenhouse 目前能真正提交,其他平台仍会走改写与审核流程,然后在 cmd/auto-apply 处以一个诚实的具名理由停泊。
  • 基础 CV 存在性:没有则 409 add a résumé before using auto-apply。
  • 是否已投递:GetUserJobApplied 为真则 409。这一步不可省略,因为 cmd/auto-apply/store.go 的 Submit 会在标记已投递的同一事务里删掉队列行,ON CONFLICT 兜不住"真正提交后再次点击"。
  • 资格闸门:工作授权与地点/办公模式的硬约束不匹配时拒绝(或仅在影子模式记录日志)。由 AUTO_APPLY_ELIGIBILITY_ENFORCE=1 控制,默认关闭,且每次调用都读环境变量而不缓存——缓存会把测试二进制里 t.Setenv 的效果冻结。
  • 额度扣费(chargeAutoApply)——最后一道,因此前面任何一条拒绝都不花费额度。
  • 入队:EnqueueAutoApply 的 INSERT 赢下竞态时才 publishSubmit;撞上 pgx.ErrNoRows 时读取既有条目,按 autoApplyEntryStatus 区分"已被候选人拒绝""执行失败/阻塞""仍在队列中"三种状态,分别返回 409、409、200。
  • 这条顺序正是集成测试断言的对象。TestAutoApply_ARequestRefusedBeforeTheAllowanceSpendsNothing 用一个没有 CV 的 pro 账号请求,期望 409 且 usage_daily 中的 auto-apply 用量为 0。


    五、配置与环境变量

    所有可调项都遵循既有命名约定。注意后缀的大小写与连字符规则:feature 的值(如 auto-apply、cover-letter)被持久化在账本中并被幂等索引匹配,不能改;但 systemd 的 EnvironmentFile= 不接受变量名里带连字符,所以后缀要把 – 写成 _(envSuffix,internal/ai/plan/env.go)。

    变量作用说明
    STRIPE_PRICE_IDS 赋予 Pro 的价格 id 列表 为空则无人是 Pro;Enabled() 要求它非空
    STRIPE_ULTRA_PRICE_IDS 赋予 Ultra 的价格 id 列表 不设置就等于不卖 Ultra,其余行为不变
    STRIPE_API_PRICE_IDS / STRIPE_API_DATA_PRICE_IDS API 产品的两档 独立产品,不赋予任何 plan 档位
    PLAN_PRO_DAILY_<F> 覆盖 pro 档某功能的日数字 该档有上限时是上限,无上限时是公平使用守卫
    PLAN_ULTRA_DAILY_<F> 覆盖 ultra 档某功能的日数字 同上
    PLAN_ENFORCE 打开拒绝的功能列表,如 match,tailor 或 all 面向未来功能的影子模式出口
    PLAN_TAILOR_TURNS_PER_SESSION 单次改写会话的轮次上限 默认 15
    AUTO_APPLY_ELIGIBILITY_ENFORCE 1 时资格闸门真正拒绝 默认影子模式

    具体到最要紧的那个数字:PLAN_PRO_DAILY_AUTO_APPLY 必须能在不发版的情况下移动,因为它是人们已经买过的计划之下的一个天花板。这也是 envSuffix 存在的实际收益——PLAN_PRO_DAILY_AUTO_APPLY 是能在目标主机上被设置的,PLAN_PRO_DAILY_AUTO-APPLY 不是。

    一个无法解析的覆盖值会保留默认值并在日志中说明:

    func envPositive(key string) (int, bool) {
    raw := strings.TrimSpace(os.Getenv(key))
    if raw == "" { return 0, false }
    n, err := strconv.Atoi(raw)
    if err != nil || n <= 0 {
    log.Printf("plan: %s=%q is not a positive number — keeping the default", key, raw)
    return 0, false
    }
    return n, true
    }

    替代方案——一个拼写错误解析成零——会让该功能对所有订阅者关闭,而且看起来完全像一个深思熟虑的决定。

    PLAN_FREE_DAILY_<FEATURE> 不存在。它曾经能在不发版的情况下重新打开一个 free 额度;现在"free 完全得不到 AI"是产品不变量,而一个只能削弱不变量的开关,是运维会误拉的那种开关。主机上如果还留着这个值,它不会被读取。


    六、数据模型与查询

    迁移 0141 是纯增量(additive),没有回填也没有 DROP——因为从来没有人写过 Ultra 权益,没有需要按证据区分的历史:每一列都从 NULL 开始,每个账号解析到它原本就有的档位,在配置价格之前部署不改变任何事。

    查询侧新增的内容(internal/platform/db/queries/plan.sql):

    • 每个来源列一个 setter;
    • 每个 provider 一条临近到期扫描(dueSoon);
    • 一次读取同时返回两档的派生值与来源值:

    — name: GetProUntilSources :one
    SELECT pro_until, pro_until_stripe, pro_until_revenuecat, pro_until_granted,
    ultra_until, ultra_until_stripe, ultra_until_revenuecat, ultra_until_granted
    FROM users
    WHERE id = $1;

    以及档位读取:

    — name: GetPlanUntils :one
    SELECT pro_until, ultra_until …

    修改 SQL 后需要运行 make sqlc 重新生成 internal/platform/db,并提交生成的差异。任务 1.1 要求的校验命令是 pnpm check:sql。

    顺带一提,ListNewlyPayingUsersMissingWelcomeEmail 也被修正为同时考虑两档——"当前享有付费档位的账号"这个判断必须和 TierOf 问的是同一个问题,否则一次手工授予或商店订阅就收不到欢迎邮件。


    七、前端与公开接口

    /pricing 渲染三档,但页面本身不硬编码任何档位数字。公开端点 GET /api/v1/plans 返回的形状是"每功能一条,每条含三档"(internal/api/handler/plans_public.go):

    type planFeatureView struct {
    Feature string `json:"feature"`
    Free planTierView `json:"free"`
    Pro planTierView `json:"pro"`
    Ultra planTierView `json:"ultra"`
    }

    注释解释了为什么要从 free_daily 加 pro_unlimited 的旧形状改过来:那对字段携带了一个它自己都预言过不会长久的假设——"pro_unlimited 今天总是 true"——而 auto-apply 正是它不再成立的地方。旧页面没有办法渲染这一点,只会把 free 的数字显示在 pro 那一列。

    前端类型 PlansMatrix(web/src/lib/types.ts)同样为每个功能携带第三个数字;部署没有提供 Ultra 价格时,页面显示两档而不是一个死掉的第三列。

    enforced 标志必须上线,这一点被反复强调:SPA 会在额度耗尽时预先禁用动作,一个只按 used >= limit 拦截的客户端就是服务器根本没有的一堵墙——而且它压制的正是影子模式要计数的那些请求,使"是否强制"决策所依赖的数字被低估。


    八、验证方式

    任务清单第 7 节给出了完整验收路径,可原样复用:

    gofmt -l . # 应当无输出
    go vet ./…
    go test ./…
    go vet -tags=integration ./…

    go test -tags=integration ./internal/ai/plan/ ./internal/identity/billing/ ./internal/api/handler/

    pnpm check:sql
    pnpm check:links
    golangci-lint run –new-from-rev=origin/main
    pnpm –dir web lint
    pnpm –dir web test

    其中 handler 的集成测试(internal/api/handler/auto_apply_allowance_integration_test.go)断言的不是算术——那是 decide 单元测试的事——而是只有这条路由才会搞错的两件事:什么时候花费一个单位,以及拒绝说了什么。

    测试场景期望
    TestAutoApply_ProIsRefusedPastItsDailyAllowance pro 账号对 4 个不同职位请求 3 次成功、1 次 402,账本 used = 3
    TestAutoApply_UltraIsNeverRefused ultra 账号连发 6 次 全部 200
    TestAutoApply_ARepeatForTheSamePostingSpendsNothing 同一职位连点 3 次 只花 1 个单位
    TestAutoApply_ARequestRefusedBeforeTheAllowanceSpendsNothing 没有 CV 的 pro 账号 409,单位花费 0
    TestAutoApply_TheRefusalNamesTheFeatureAndItsFigures 耗尽额度后再请求 402,feature=auto-apply、used=3、limit=3、upgrade_url 非空

    任务 7.4 还要求在 Stripe stub 上走一遍端到端:Ultra 价格解析为 ultra;在该档 auto-apply 无界;在 pro 上第 4 次被拒;一次商店同步不会动到 web 端的权益。

    测试辅助函数 makeUltra 使用 SetUltraUntilGranted 授予 24 小时——和 makePro 用 granted 来源的理由一样:这里测的是"一个计划允许什么",不涉及任何支付提供方。


    九、风险与权衡

    设计文档对每个风险的处置都值得照抄:

    • 现有 Pro 订阅者失去无限 auto-apply。 涉及两个账号、该功能历史上两条队列记录。数字是环境变量,如果每天 3 次被证明是错的,不发版就能改。之后再改,就是对更多人做同样的事。
    • TierOf 改变签名,而它每次计量动作都被读。 编译器会找出所有调用者;真正的风险是某个调用者把同一个时刻传了两次,静默地把所有人解析成 ultra。所以有一条测试断言:只有 pro 权益的账号通过真实 store 解析为 pro。
    • 第一天就强制意味着一个错误的数字会拒绝真实的人。 但它会带着数字一起拒绝,而数字是可配置的。另一个选择——发布一个什么都卖不出去的档位——更糟,也更难解释。
    • 忘记 STRIPE_ULTRA_PRICE_IDS 的部署会静默地卖不出 Ultra。 但它也不改变其他任何事:pro 和 free 行为完全如前,定价页展示 API 提供的东西。价格列表缺失与整个计费包已有的"未配置即禁用"是同一形状。
    • RevenueCat 为一个它不卖的产品写 Ultra 列。 这是刻意的。代价是一列 NULL;替代方案是一个永远无法清除它从未写过的东西的 provider。

    另有一条隐含风险值得补充:设计文档提到的"enforcement on from day one"在实现中进一步演化——所有六个计量功能如今都以 enforce: true 上线。PLAN_ENFORCE 保留下来,是给未来功能做影子模式的出口,而不是给这六个用的。


    十、迁移与回滚手册

    设计文档的 Migration Plan 是一份可以直接照做的四步操作:

  • 迁移 0141_ultra_until_sources.sql 加上三个来源列和派生的 ultra_until。纯增量;make sqlc 重新生成查询。
  • 部署。 没有配置 Ultra 价格时,没有人解析为 ultra,唯一可见的变化是 auto-apply 的 pro 天花板。
  • 手工在 Stripe 创建 Ultra 价格,并在主机上的 STRIPE_ULTRA_PRICE_IDS 中命名它。定价页会自动拾取。
  • 回滚就是取消 STRIPE_ULTRA_PRICE_IDS——没有人会新解析为 ultra,已经在 Ultra 上的人保留他们已付费的时间直到到期。撤销 auto-apply 天花板则是把 PLAN_PRO_DAILY_AUTO_APPLY 设高,同样不需要发版。
  • 有一点在 0135 的注释里被明确指出、而 0141 恰好避开了:0135 是先 DROP 再重新添加生成列,于是在迁移与发布切换之间,旧二进制对着新 schema 的 SetProUntil 会以 428C9 失败——它失败关闭,一次投递会不被确认然后重试。0141 只做添加,没有这个窗口。


    十一、留给后续的两个问题

    设计文档诚实地把两个问题留在 Open Questions,而不是假装已经解决:

  • Ultra 订阅者的 auto-apply 到底要不要公平使用守卫? Pro 的守卫设在人类行为的二十倍之上,保护的是网关而不是价格。auto-apply 的成本是一个浏览器而不是一次模型调用,所以保护主机的数字是另一个数字,而且目前没有任何测量。计划是先带一个刻意宽松的数字上线,一个月后再读它。 这也解释了为什么 Ultra 的 auto-apply 配置是 {daily: 100, unlimited: true}——100 不是卖点,是保护宿主机的兜底。
  • 中途升级的人会怎样? 门户会按比例计费,而且两个订阅可能同时生效一小段时间——这已被"Ultra 优先"规则覆盖。是否还应替他们取消 pro 订阅,是客服问题而非代码问题,在它发生两次之前。

  • 十二、这套设计的可迁移经验

    如果把这篇设计浓缩成几条可以搬到别处的规则:

  • 当"谁写的"会影响"写了什么"时,按来源拆列,让派生值无写入者。 用生成列把错误写入变成不可执行(428C9),而不是写在文档里求人遵守。
  • 一个外部系统的状态,一次读取、整体推导、整体写入。 两次读取会落在变更两侧;两次写入会留下一个"哪档都没有"的瞬间。
  • "我还赋予什么"必须可表达。 一个无法写出"我什么都不赋予"的 provider,也就无法执行取消。
  • 幂等来自键的选择,而不是来自加锁。 把引用设成职位 id,双击、重试、页面刷新就都变成了同一个已付费的引用。
  • 额度配置用"每档一个值",不要用"free 数字 + pro 例外"。 一旦出现第三种形状(这里是有真实日上限的 pro),特殊字段的交互就没人读得懂。
  • 免费的边界是产品不变量,不是可配置的数字。 一个只能放松不变量的开关,早晚会被误拉。
  • 如果你想沿着这条路径继续读代码,建议顺序是:migrations/0141_ultra_until_sources.sql → internal/ai/plan/plan.go → internal/ai/plan/decide.go → internal/identity/billing/entitlement.go → internal/api/handler/auto_apply_enqueue.go;对应的规范文本在 openspec/changes/add-ultra-plan/specs/plan-tiers/spec.md 与 openspec/changes/add-ultra-plan/specs/auto-apply-allowance/spec.md。

    赞

    分享

    【免费下载链接】freehire

    freehire — the open-source search engine for job seekers

    项目地址:
    https://gitcode.com/GitHub_Trending/fr/freehire

    点击查看 免费下载

    上一篇:
    DLSS指示器5分钟快速配置:从零开始的完整操作手册

    下一篇:
    DLSS Swapper终极指南:轻松管理游戏DLSS版本的完整解决方案

    创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

    赞(0)
    未经允许不得转载:171主机测评 » freehire 三档订阅设计解析:从 `pro_until` 到 Ultra,以及把 auto-apply 变成计量功能
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址