【免费下载链接】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 一节点出了两件事,而它们本质上是同一个问题:
两件事互为因果:只有先把动作计量起来,才能在它上面定价一个新档位。
设计文档还给出了时间窗口上的判断:生产环境当时只有两个付费订阅者、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)如下:
| 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
}
三个细节都值得记住:
读取侧同样是"一行、一次查询"(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),闸门依次是:
这条顺序正是集成测试断言的对象。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 是一份可以直接照做的四步操作:
有一点在 0135 的注释里被明确指出、而 0141 恰好避开了:0135 是先 DROP 再重新添加生成列,于是在迁移与发布切换之间,旧二进制对着新 schema 的 SetProUntil 会以 428C9 失败——它失败关闭,一次投递会不被确认然后重试。0141 只做添加,没有这个窗口。
十一、留给后续的两个问题
设计文档诚实地把两个问题留在 Open Questions,而不是假装已经解决:
十二、这套设计的可迁移经验
如果把这篇设计浓缩成几条可以搬到别处的规则:
如果你想沿着这条路径继续读代码,建议顺序是: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),仅供参考



