【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 internal/ai/plan/AGENTS.md 为核心骨架,解析 freehire 中「用户处于哪个套餐、每个计量功能每天能用多少、还剩多少、拒绝是否已经打开」这一整套约定的设计意图与落地实现。读完后你将掌握该包的判定链顺序、幂等键与事务边界、裁剪会话的双重上限、环境变量调参方式,以及影子模式如何通过enforced字段一路传导到 Web 与扩展端的按钮状态。
一、包的设计定位:只回答一个问题
internal/ai/plan的包注释把职责收敛得非常窄:调用方给出一个 feature 和一个 ref,拿回「是/否」。plan.go 明确了这一点,而 AGENTS.md 用一句话概括了边界:
One question in, yes or no out:
Consume(user, feature, ref).
六个计量功能中有五个是 AI 功能,成本是一次模型调用;第六个 auto-apply 是无人值守的浏览器提交申请,因此凡是需要区别对待的地方,它的形态都不同。
关键结构上的取舍是规则与存储分离:
- 判定本身是纯函数
decide(以及会话维度的decideTurn),只依赖套餐、功能、当天已用量、该引用是否已计费,以及传入的时间点; - store.go 只负责包住这个纯函数的事务,不做别的判断。
这样做的收益在 decide.go 的注释里写得很直白:所有分支(包括很难通过事务触发的那些)都能脱离数据库直接测试。这一点在 decide_test.go 里得到了印证——影子模式、公平使用守卫、未配置功能、免费层拒绝等用例全部只调用decide,没有建库。
为什么放在ai而不是identity
AGENTS.md 记录了一个分层约束:该包位于ai块,通过platform/db读取users.pro_until。identity与ai处于同一层,若把包放在identity,ai/assistant与ai/speech就无法调用它——而六个计量功能中有两个正住在那里。规则满足、代码却写不出来的方案没有意义。这条约束在分层检查里也有对应实现:internal/platform/arch/layering/blocks.go。
二、三层套餐:Tier与TierOf的解析规则
plan.go 定义了三个可购买的层级,并明确没有给员工留第四层——只存在于代码里的豁免,在数字看起来不对时没人能看见。
const ( TierFree Tier = "free" TierPro Tier = "pro" TierUltra Tier = "ultra" ) func (t Tier) Paid() bool { return t == TierPro || t == TierUltra }Paid()的存在是一个被文档反复强调的教训:任何表达「有人在付费」的规则都必须写Tier.Paid(),绝不能写== TierPro。AGENTS.md 中记录了第一次实现时正是这个写法把 Ultra 排除在裁剪会话的轮次上限之外,结果是买了更贵的套餐反而比便宜的套餐更早被掐断。写成Paid()之后,未来新增第四层会自动继承该规则。
解析入口TierOf(proUntil, ultraUntil, now)的规则是「更好的那一层只要还活着就赢」:
func TierOf(proUntil, ultraUntil, now time.Time) Tier { if ultraUntil.After(now) { return TierUltra } if proUntil.After(now) { return TierPro } return TierFree }三条性质值得记住:
- Ultra 优先,即使 Pro 的到期时间更远。升级期间两个权益同时存活是常态(AGENTS.md 指出支付服务商的门户会让这变得很普通),一个「花更多钱反而降级」的解析结果是最糟糕的 bug 形态。
- 比较是严格的(
After),权益在到期那一刻就结束;额外的宽限期属于产品决策,应该放在能被看见的地方,而不是藏在这个函数里。 - 读取只查一行、不调账单服务商。store.go 的
Tier只调用一次GetPlanUntils,查询见 queries/plan.sql。一次查询而不是每层一次,是因为两次读可能落在同步的两侧——「从两个瞬间解析出的层级」会让人瞬间变成哪个层都不是。NULL 列读出零值时间,TierOf自然把它当作「什么也没覆盖到」,因此不需要额外分支。
三、六个计量功能与出厂额度表
plan.go 定义了 feature 常量,并强调其值会被持久化进账本、被幂等索引匹配,必须保持稳定:
| Feature 常量 | 值 | 含义 |
|---|---|---|
FeatureTailor | tailor | 一个裁剪会话(有自己的双重上限) |
FeatureFit | match | 一次职位匹配分析 |
FeatureAssistant | assistant | 助手对话,覆盖 chat 与 profile 预设 |
FeatureDictation | dictation | 语音转写 |
FeatureCoverLetter | cover-letter | 一封求职信草稿(3 次模型调用) |
FeatureAutoApply | auto-apply | 一次无人值守网申(驱动浏览器) |
FeatureAssistant故意把 chat 与 profile 预设合并:它们是同一个对话界面指向不同对象,按预设分开计量只会教用户该敲哪个名字。而 tailoring 不属于它——裁剪由自己的两道边界计量,若在这里再计一次,会变成「每日助手额度决定一份 CV 能改多深」。
额度表的实际形状由featureConfig+tierAllowance表达(plan.go):
type tierAllowance struct { daily int // 真实上限;Unlimited 时它是公平使用守卫 unlimited bool } type featureConfig struct { pro tierAllowance ultra tierAllowance enforce bool }零值意味着什么也不允许,这正是「未配置」应该有的行为。featureConfig里没有 free 字段,原因见下一节。
DefaultConfig()(plan.go)的出厂数字如下:
| Feature | Pro | Ultra | 形态 |
|---|---|---|---|
tailor | 40(公平使用守卫) | 120(公平使用守卫) | 每日会话数,Unlimited |
match | 60(守卫) | 180(守卫) | Unlimited |
assistant | 200(守卫) | 600(守卫) | Unlimited |
dictation | 200(守卫) | 600(守卫) | Unlimited |
cover-letter | 60(守卫) | 180(守卫) | Unlimited |
auto-apply | 3(真实上限) | 100(守卫) | Pro 是硬上限,Ultra 不限但有守卫 |
TailorTurnsPerSession | 15 | 15 | 会话轮次上限 |
三点必须准确理解:
- 「一个套餐一个额度,无限制时那个数字是公平使用守卫而不是上限」。这就是
decide里一个层级名字都不出现的原因——它从 allowance 上读Unlimited,然后对任何带守卫的层级施加守卫。这个形状取代了早期的「免费数字 + Pro 公平守卫」两字段设计,因为那只能表达「免费有上限、Pro 没有」,而 auto-apply 需要第三种形状:Pro 上的真实每日上限。 - PRO/ULTRA 的数字仍是未经影子运行验证的读数(文档写明是 101 名用户 23 天、完全无付费墙条件下的读数)。变的不是数字被重新验证,而是 FREE 现在什么都拿不到。
- auto-apply 的 Ultra 守卫故意放宽且坦承未经测量:它守的是宿主(每次尝试都是一个浏览器),不是价格,而且这个功能历史上只跑过两次。
四、decide:一条顺序即语义的判定链
decide.go 是整个规则的核心。返回的Decision同时携带判定结果与渲染拒绝所需的一切:
type Decision struct { Allowed bool Tier Tier Feature Feature Used int // 判定之后的位置,不是之前 Limit int Unlimited bool ResetsAt time.Time Charge int // 调用方需要记账的量:拒绝为 0,已付费引用也为 0 Shadowed bool // 开着强制的话本会被拒 FairUse bool // 拒绝来自公平使用守卫而非套餐上限 Enforced bool // 该功能的拒绝是否真的生效 }判定顺序本身就是规格,任何一步前移或后移都会改变产品语义:
- 未付费层级先被拒,甚至早于「是否已计费」分支。理由是一个重算仍然是模型调用,订阅失效不会继续为其买单。这也是为什么不存在 free 额度可配置:
featureConfig没有 free 字段,PLAN_FREE_DAILY_<FEATURE>不再被读取,WithFreeDaily已被删除。文档对这条保留了一个很锋利的注解——一个「只能削弱不变量」的开关,就是运维会误拉的开关。 - 已计费的引用无条件放行且不再计费。重算、重试、重连都是已经付过钱的工作;拒绝它等于让用户为看自己的结果付第二次钱。测试 TestDecideChargesAnAlreadyPaidRefNothing 明确断言了即使在 99/3 的越界状态下也应放行。
- 未配置的功能一律拒绝(
Limit == 0 && !Unlimited)。某处忘了配置,应该在影子运行里显示为「被拒」,而不是在账单里显示为「没算」。 - 公平使用守卫只属于 Unlimited 层级,且不受强制开关影响。有真实每日上限的层级已经被它约束;在那里触达守卫意味着额度被配到了守卫之上,那是要修配置,不是要指控调用方自动化。守卫约在人类行为之上 20 倍,所以触达它的是自动化,它保护的是网关而不是价格。它在触发时会写日志(store.go),因为「没人看见开火的守卫」会被安上它并未造成的故障。
- 在额度内则放行并计 1(
Charge = 1,Used = used+1)。 - 超出套餐上限:强制开启时拒绝;强制关闭时仍然记账并放行,同时打上
Shadowed——「跳过它本该拦下的请求」的计数器会描述一个并未发生的一天。
补充一个容易忽略的分支:decide在层级不是Paid()时直接返回,但其余字段仍然填充,这样拒绝时渲染出的 402 响应体与其他拒绝完全一致(TestDecideRefusesFreeWhateverElseIsTrue 逐一校验了Charge == 0、Shadowed == false、FairUse == false以及ResetsAt非零)。
时间与重置:惰性、UTC
plan.go 只有两个时间函数:
func Day(t time.Time) time.Time { u := t.UTC() return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) } func ResetsAt(t time.Time) time.Time { return Day(t).AddDate(0, 0, 1) }单位是 UTC 日历日,重置是惰性的:新的一天是新的主键,因此没有任何东西需要过期,也没有定时任务会忘记跑。这直接体现在usage_daily的主键(user_id, feature, day)上。
五、store.go:把纯函数包进事务
Consume的流程(store.go)值得逐步走一遍,因为每一步都在修一个具体的并发或幂等问题:
- 取
now、算Day、解析Tier; - 开启事务,
EnsureUsageDay先播种今天的计数器行——INSERT ... ON CONFLICT DO NOTHING(plan.sql)。这一步是为了让随后的SELECT ... FOR UPDATE永远有行可锁,正是这把锁串行化了两笔同时发生的「首次消费」,使额度不会被竞态超卖; GetUsageDayForUpdate读出当天已用量;ConsumptionExists判断该(feature, ref)是否已计费;- 调用
s.cfg.decide(...); Charge > 0时写usage_ledger(append-only 消费行)并SetUsageDay更新计数器;- 提交。
注意Decision.Used是判定之后的位置,测试 TestDecideWithinAllowance 特意断言这一点。
预留先于工作
Consume的注释说明了顺序:预留发生在它所支付的工作之前。这是刻意的,也正是Release存在的原因——事后计费会让中途断连的用户拿到没人付钱的结果;只计费却没有回退路径,则会为一个从未到达的回答向用户收费。
Release:重新盖章而不是删除
store.go 与 plan.sql 共同实现了一个精细的语义:释放不是删除行,也不是追加一条补偿行,而是把kind从consume改写为release。
原因是索引形状。usage_ledger_consume_ref_uniq是部分唯一索引,作用域为WHERE kind = 'consume'(0121_usage_ledger.sql),于是:
- 追加补偿行:原行仍在,ref 永久花掉,用户重试永远免费;
- 删除行:ref 释放了,但「曾经预占过」这个事实被抹掉;
- 重新盖章:两件事都做到,而且当天计数器(只对
consume求和)保持正确。
还有两条顺序性质:
- 释放归还的是它当初扣下的那一天,不是它发生的那一天。
GetConsumptionDay在拿任何锁之前读出记账日。23:59 预占、00:01 释放,若记到今天,就等于把用户今天没花过的额度还给他,而真正花掉的那天仍然是花掉的。 - 加锁顺序固定:先
usage_daily,后usage_ledger。同一用户的消费与释放因此不会互相死锁。
Release对「从未计费过的东西」是安全的空操作(pgx.ErrNoRows直接返回 nil),这才让所有失败路径都可以不加判断地调用它。
六、裁剪会话:两道边界与「槽位而非行数」
AGENTS.md 用很大篇幅讲这件事,因为它是这个包最容易写错的部分。
两道边界拦的是不同的事:每日会话数限制候选人一天能处理多少个职位;每会话轮次上限限制其中某一个能走多深。只保留任何一个都会留下漏洞——生产上三个八月的测量显示,中位会话跑 2.7 轮,有一个跑了 54 轮,而那个账号只开了两个会话(远在任何每日计数之内),却消耗了 25 美元的模型调用。
轮次上限没有自己的存储。它是会话存活计费所达到的最高槽位号,写成<session_id>#1、#2……(session.go):
func sessionRef(sessionID string, n int) string { return sessionPrefix(sessionID) + strconv.Itoa(n) } func sessionPrefix(sessionID string) string { return sessionID + "#" }这个设计是「顺着幂等键长出来」而不是「和它对抗」:裸会话 id 只能被计费一次,对「开启会话」正确、对「延长会话」错误;而#n让延长成为一个仍然幂等的独立事件。两个同时点击「继续」的请求会尝试同一个下一引用,在今天计数器行上串行化,第二个读作「已计费」——竞态只花掉一份额度,不需要自己的锁。
#终止符是承重的:没有它,扫描sess-1会把sess-12的计费一起扫进来。
用槽位,不用行数。两者在两种关键情况下不同,且都会白吃候选人一次会话:
- 释放会把一行置为 void 但不「退售」槽位,按行计数会让已经付过费的会话的上限倒退;
- 隐式获得第一个上限的会话根本不持有任何行,按行计数会把它第一次延长卖成槽位 1,落回它已有的上限上。
ceilingsHeld(session.go)是唯一授予隐式上限的地方:
func ceilingsHeld(highestSlot int) int { if highestSlot < 1 { return 1 } return highestSlot }没有计费的会话隐式获得槽位 1,而不是 0。部署当天所有活着的会话都早于这本账;把「没有计费」当成「从未付费」会让每个在线对话的下一轮都 402。它仍然有界——一个额度那么多,且计费发生在会话创建处,那是唯一能在任何东西存在之前就拒绝的地方。授予为槽位而非裸上限,才让第一次延长买的是槽位 2、因而真的买到了东西。
配套方法一目了然:
StartSession→Consume(..., sessionRef(sessionID, 1)),注意它在会话被创建时计费,而不是工作区被打开时——工作区按职位寻址,刷新会重跑 bootstrap,那时计费等于为按 F5 收钱;ExtendSession→ 先算ceilingsHeld,再买held+1;ReleaseSession→ 归还第一次计费;AllowTurn→ 读层级 + 最高槽位,交给decideTurn。
decideTurn(session.go)本身极短:
if tier.Paid() { d.Allowed, d.Unlimited = true, true return d } d.Ceiling = ceilingsHeld(highestSlot) * c.TailorTurnsPerSession轮次上限只约束未付费层级,而且由于付费门禁让任何未付费账号根本到不了「跑一轮」这一步,它在生产上目前不约束任何人。它作为机制被保留(测试通过「会话买下后订阅失效」来覆盖);付费层级是否应带上限,是产品决策,不是这个包能自己定的。
另外两条容易踩的规则:
- 计费单位是一轮,不是模型调用。一轮平均 7.1 次调用,而这个数字对提问者不可见;按调用计费会让两个看起来一样的请求因为没人能看见或影响的原因而价格不同。
- 裁剪轮次不消耗任何助手额度。重复计费会让每日助手额度决定一份 CV 能改多深。它的拒绝必须命名为 TAILORING 功能,因为候选人要做的是再花一次今天的裁剪会话。
七、环境变量:三组杠杆与两个陷阱
env.go 是DefaultConfig()加环境覆盖。doc 注释把「两件值得不部署就能调的事」区分得很清楚:
# 逐功能打开拒绝(影子运行的终点),或用 all PLAN_ENFORCE=match,tailor PLAN_ENFORCE=all # 移动数字本身。每个层级一个变量,同时移动「上限」与「公平使用守卫」 PLAN_PRO_DAILY_TAILOR=40 PLAN_ULTRA_DAILY_TAILOR=120 PLAN_PRO_DAILY_AUTO_APPLY=3 PLAN_ULTRA_DAILY_AUTO_APPLY=100 # 每会话轮次上限 PLAN_TAILOR_TURNS_PER_SESSION=15陷阱一:后缀用下划线,值不能用下划线
这是一个真实的可运维性问题。feature 的值cover-letter、auto-apply被持久化在账本里并由幂等索引匹配,值不能改;但 systemd 的EnvironmentFile=不接受变量名中的连字符,逐字拼出的PLAN_PRO_DAILY_AUTO-APPLY在唯一需要它的主机上根本无法设置。所以:
PLAN_*_DAILY_<F>的后缀把-写成_:PLAN_PRO_DAILY_AUTO_APPLY;- 而
PLAN_ENFORCE按账本里存储的原样命名 feature:auto-apply。
两种拼写不同,是因为只有其中一个是环境变量名字。实现见 envSuffix:
func envSuffix(f Feature) string { return strings.ToUpper(strings.ReplaceAll(string(f), "-", "_")) }陷阱二:拒绝读不懂的覆盖,而不是让它归零
envPositive(env.go)只接受正整数;解析失败或<= 0时保留默认值并打印日志。替代方案——拼写错误解析成 0——会拒绝所有订阅者的该功能,而且看起来完全像一个刻意的决定。
enforcedFeatures同理:PLAN_ENFORCE中匹配不到任何 feature 的名字会被忽略并记日志,而不是猜测(把mach解析成match等于凭空造出一个没人敲过的开关)也不是静默丢弃(那会让运维确信自己打开过什么)。
同样地,不存在PLAN_FREE_DAILY_<FEATURE>。它曾经可以在不部署的情况下重新打开免费额度;如今「免费完全拿不到 AI」是产品不变量,主机上残留的值只会被忽略。旧的PLAN_FAIR_USE_<FEATURE>也已删除——同一个字段两个名字,等于每个读者都要回答两遍同一个问题。
八、影子模式与enforced的端到端传导
影子模式是逐功能的开关:关掉时Consume记录并上报,但从不拒绝。它回答的问题是「这会有多少人被拦下、在他们一天的什么位置」。
当前所有计量功能出厂即强制(enforce: true),所以这个开关在这些功能上已无东西可打开;它留着是给下一个上线的功能用——未来的影子运行仍然能逐个功能收尾,不需要部署。
这里有一条极易写错的规则:
预检查必须问
Refuses,永远不要问Exhausted。
Exhausted只说额度用光了;Refuses说额度用光了且这个功能真的强制(store.go):
func (s Standing) Exhausted() bool { return !s.Unlimited && s.Used >= s.Limit } func (s Standing) Refuses() bool { return s.Exhausted() && s.Enforced }基于前者做预检查,会在其他所有界面还只是「计数」的时候硬性拒绝——这就是「一个没人打算强制就上线了强制」的典型路径。Refuses是Standing上的方法而不是Store上的方法,同样是为了让标志与规则一起走。
这条规则同样约束 SPA,这就是enforced要上线的原因。每个 API 返回的 allowance 都携带它:
{ "feature": "match", "used": 3, "unlimited": true, "enforced": true, "resets_at": "2026-10-23T00:00:00Z" }- 序列化形状见 plan_view.go。注意
Limit带omitempty:Unlimited 调用方不下发数字,因为把公平使用守卫当limit发出去,等于把基础设施防御呈现成用户买的东西。 - 前端唯一允许用来隐藏/禁用动作的是 web/src/lib/allowance.ts 中的
refuses(),其孪生实现位于extension/lib/freehire.ts:
export function refuses(a: Allowance | null | undefined): boolean { return isSpent(a) && a?.enforced === true; }isSpent被刻意设为不导出——单独使用它是每个调用点都会出错的错误问题。仅按used >= limit拦截的客户端,是服务端并不存在的一堵墙;更糟的是它会压制影子运行正要统计的那些请求,使强制决策所依据的数字系统性偏低。
同一文件还区分了两种拒绝的措辞(allowance.ts):notInPlan判断「套餐根本不含这个功能」而非「用完了」。二者都拦截,但要求用户做的事相反——一个请他重置后再来,一个请他升级。
服务端的措辞逻辑在 plan_view.go 的refusalMessage与planIncludes,注释里记录了这次修复:免费层所有计量功能的额度都是 0,于是used >= limit在0 >= 0时即成立——一个整天什么都没跑的免费账号被告知「你已用完今天的职位分析」,而给出的重置时间也不会改变任何事。
九、拒绝语义:402 必须先于流、计量 fail-open、auto-apply 例外
402 必须在流打开之前
拒绝是HTTP 402,且必须在响应头发出之前写出。头部出去之后再写,就变成一个 200 里的事件,任何检查状态码的东西都看不见。
此外还有一个 Fiber 层面的坑:plan_view.go 的c.Status(402).JSON(...)返回 nil,所以「它拒绝了吗」无法从 error 推断。handler 的接缝因此在一侧额外返回一个显式的 bool——assistant_meter.go 明确记录了「这件事曾经发生过一次」,现在由「402 上不得出现事件流」的测试兜住。
计量 fail-open,auto-apply 例外
AGENTS.md 把这条写成了一节:每个计量器都 fail-open。读不出计数器就记日志、放行且不计费。记账绝不该拒绝一个合法请求;一个未计费的轮次,比一个被我们自己的记账拦下的候选人错得更小。真正的上限是原子消费本身,不是它前面的预检查。
唯一的例外是 auto-apply,而上面这条规则正是它成为例外的原因。其他地方动作是模型调用、成本由我们吸收,未计量只是舍入误差;auto-apply 驱动浏览器,以某人的名义向真实雇主提交真实申请——未计量不是我们能吸收的,也不可撤销。「我们无法判断你是否可以」的诚实回答就是说出来,所以chargeAutoApply在计费出错和计量器缺失这两种情况下都返回 503。这个包里其他任何东西要照抄它,必须先给出同样形状的论证。
付费门禁:RequirePaid是同一问题的单独问法
paidai.go 处理另一类调用:根本没有自己额度的 AI 动作(ATS 评审、抽取重跑、邮件召回、自动填充)。
func (s *Store) RequirePaid(ctx context.Context, userID int64) (Tier, error) { tier, err := s.Tier(ctx, userID) if err != nil { return "", err } if !tier.Paid() { return tier, ErrPaymentRequired } return tier, nil }两个错误必须分清:ErrPaymentRequired表示「套餐从来不含这个,等到午夜也不会变」;ErrRefused表示「今天的额度花光了」。
读取失败返回原始错误、不返回层级,调用方必须拒绝(503),绝不授权。这是与计量器 fail-open 相反的方向,因为它保护的是模型花费,不是记账。
标记机制把门禁与模型客户端绑定起来:
WithPaidAI/PaidAI:只有RequirePaid对该用户答过 yes 之后才能调用;WithFreeAI/FreeAI:RequirePaid的唯一具名例外,给每个账号都能用的调用——存储的 CV 解析,以及「用 AI 描述」的搜索解析。
internal/api/handler的用户调用绑定在没有这些标记时拒绝发放模型客户端,因此一个挂载时漏了门禁的路由会在第一次请求(以及第一个测试)就失败关闭,而不是悄悄为没付费的人花钱。门禁中间件与适配层见 paid_ai.go,其中entitlementOf特意保持 nil 存储为 nil——把 nil*plan.Store装进接口会读作「已配置」,在第一个请求解引用崩溃。
十、数据模型:账本、计数器与命中记录
usage_ledger与usage_daily
0121_usage_ledger.sql 的头部注释完整交代了取舍:
usage_ledger是真相来源:每次消费、释放或授予一行;usage_daily是热路径读的物化计数器,让门禁永远不用求和账本,且随时可从账本重建;- 新建表而不是改造
credit_ledger:扩宽 feature 的 CHECK 并把 period 从'YYYY-MM'换成日,会让同一列承载两种不兼容的含义;把每月余额称作「该月第一天」,更是往 append-only 账本里写一件从未发生过的事。 day用date而不是文本,因为一天确实是一个日期,类型能拒绝文本会接受的笔误。- feature 列没有 CHECK。
credit_ledger只允许('match','tailor'),这正是「计量助手」需要一次迁移的原因——约束把「多计量一个东西」变成了 schema 变更。计量集合是产品决策,在 Go 的 plan 配置里决定;在这里再存一份只会和它不一致。
索引与表结构:
CREATE UNIQUE INDEX usage_ledger_consume_ref_uniq ON public.usage_ledger (user_id, feature, ref) WHERE kind = 'consume'; CREATE INDEX usage_ledger_user_id_created_at_idx ON public.usage_ledger USING btree (user_id, created_at DESC); CREATE INDEX usage_ledger_user_feature_day_idx ON public.usage_ledger USING btree (user_id, feature, day);kind的 CHECK 允许('consume', 'release', 'grant')——grant为将来的授予路径预留。
plan_limit_hits
0178_plan_limit_hits.sql 记录每一次真实套餐上限导致的拒绝:不记公平使用守卫(FairUse),也不记影子模式下的放行(Shadowed)。
主键是(user_id, feature, day)且ON CONFLICT DO NOTHING,不是 append-only:nudge 只需要知道某天撞过墙,不需要知道撞了几次;append-only 会让一个不断重试的账号一天长出一行又一行。
写入路径在 store.go 的recordLimitHit,尽力而为、在已提交事务之外:拒绝必须送达调用方,与这次写是否落库无关;为它重试整个Consume会让没人要求付费的功能被计费两次。消费方是cmd/limit-nudge-mail,其候选查询只筛选免费层账号(尽管 Pro 上带真实上限的 auto-apply 也可能落行)。
十一、刻意缺席的东西
AGENTS.md 专列一节讲「故意不存在的东西」,这在设计文档里很少见,但对后来者极有价值:
credit_ledger/credit_balances仍存在,但没人写入,只被账号删除读取,等到新路径跑得足够久、没人想要旧数字时再删除。迁移它们的行被否决:把一个月余额称作「该月第一天」,就是往 append-only 账本里写一件从未发生的事。- 贡献一个 board 在这里赚不到任何东西。一次性行为无法为每日额度充值,而把它存起来留给以后,正是已被撤销的余额形状。奖励改在
add-invites里以「Pro 天数」的形式回归。 - 没有员工第四层(见第二节)。
- 没有 free 额度字段、没有
WithFreeDaily、没有PLAN_FREE_DAILY_*(见第四节与第七节)。
十二、接入与自检清单
如果你的目标是给这个包新增一个计量功能或接入一个新路由,按文档与源码可归纳出以下检查点:
- 加 feature 常量并加入
AllFeatures()。测试会遍历它,usage 界面按它上报——加进配置却忘了这里,就是用户看不见的功能(plan.go)。 - 在
DefaultConfig里配 pro 与 ultra 两个 allowance,并设enforce。零值意味着拒绝,忘记配置会在影子运行里显示为被拒而不是免费。 - 选对形态:是真实每日上限(
unlimited: false,如 auto-apply pro 的 3),还是不限量 + 公平使用守卫(unlimited: true,数字即守卫)。 - 选择 ref 的幂等粒度。它是重试/重连免费、双击只花一份额度的全部机制;裁剪会话用
<session>#n,助手轮次用<session>#turn-n(assistant_meter.go)。 - 付费门禁挂对位置:计量路由用
paidAIGate(feature);没有自己额度的 AI 路由传零Feature;为别人(队列条目所有者)执行的路径用authorizePaidAI。 - 预检查只问
Refuses(),并把enforced发上线。 - 拒绝放在流之前,并注意
c.Status(402).JSON(...)返回 nil,需要显式的 bool。 - 失败路径调
Release(盲调安全),且用已分离的 context——客户端中途断开会取消请求 context,在同一个 context 上释放连事务都开不了,而这正是释放存在的场景(assistant_meter.go)。 - 不要给计量器加 fail-closed;除非你能给出与 auto-apply 同形状的论证(真实第三方副作用、不可撤销、不可吸收)。
小结
internal/ai/plan的价值不在于「限额」这件事本身,而在于它把一组容易散落各处的判断收敛成了一个可测试的纯函数,并用事务只包裹副作用。三句话可以概括它的世界观:
- 层级用
Paid()表达,不用具体层级名,因为「更贵的套餐反而更早被掐断」是最难堪的 bug; - 判定顺序即产品语义,未付费 → 已计费 → 未配置 → 公平守卫 → 额度内 → 上限/影子;
- 计数可以失败放行,提交不可以,而唯一例外是把真实申请送到真实雇主面前的 auto-apply。
原始约定见 internal/ai/plan/AGENTS.md,实现分布在 plan.go、decide.go、store.go、session.go、env.go 与 paidai.go,接口层的接缝在 plan_view.go 与 paid_ai.go,前端只允许通过 web/src/lib/allowance.ts 判断是否拦截。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
freehire 冷启动定制:用 autopilot 取代 fit analysis 门禁的设计与实现
freehire 冷启动定制:用 autopilot 取代 fit analysis 门禁的设计与实现 freehire 的 Tailor 工作区在第一次打开某
Dozzle Cloud 套餐与限额完全指南:事件计量、超额采样与数据保留机制
Dozzle Cloud 套餐与限额完全指南:事件计量、超额采样与数据保留机制 本文面向使用 Dozzle Cloud(Dozzle 的云端告警与日志分析服务)
运维观测日志分析可观测性后端Dozzle Cloud 套餐与配额详解:Plans & Limits 全解析
Dozzle Cloud 套餐与配额详解:Plans & Limits 全解析 Dozzle Cloud 是自托管 Dozzle 的可选托管服务:你的 Dozz
运维观测日志分析可观测性后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考