资讯动态

freehire 商业 API 设计解析:付费密钥、过滤订阅源与变更 Webhook

发布时间:2026/10/23 2:16:56 来源:尧图企业网站定制
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载freehire 的公共读 API 长期免费、仅按 IP 限流结果被目录镜像站当成免费的全量数据管道。本文基于仓库中的商业 API 设计文档逐层拆解它的产品分层、Stripe 权益模型、四类调用者的限流网关、Feed 与变更事件的两索引方案、Webhook 投递与快照机制并对照已落地的源码说明设计稿与实现之间的差异。一、为什么需要一个付费 API而不是更多请求设计文档 2026-10-19-commercial-api-design.md 的出发点是一组生产环境实测数据2026-10-19仅统计/api/v1/取自 nginx access log当天有24,859 个不同 IP调用了 API但只有34 个单日请求量超过 2,000。这 34 个几乎全是自我命名的目录镜像aarivi-catalog-sync20.6 万次请求其中 12.2 万次已经被 429 拒绝、VivekJobBoard4.86 万、KerrasTechJobRadar4.18 万、freehire-dump2.24 万、alligator-croningest2.07 万、careerx1.9 万、OpenTalentJobsDump1.29 万。它们的抓取方式是各种绕路把/jobs/sitemap按 5 万条切片翻页、每个来源轮询一次posted_within_days3、或者按 slug 逐条抓取。现有 API 从不告诉它们什么变了什么关闭了。主要 API 客户端 ai-job-search skill 覆盖 4,814 个 IP日均仅 14 次请求但会突发到 200–300 次/分钟。因此每分钟紧上限会打断它每日上限则不会。全目录最近 24 小时新建约58.8 万条在招职位。竞争格局方面文档记录了 17 家厂商的调研结论按职位记录计费时镜像一个 200 万条职位的目录约需 $1k–3k/月变更流从 $175–765/月 起全量 dump 为 $1,750 或联系销售且几乎没有厂商声明数据许可。结论很明确付费产品卖的不是更多请求而是过滤后的订阅源、带关闭事件的变更流、文件快照以及一份清晰的数据许可——并且是统一价格。二、三层定价与能力矩阵Free无密钥API — $29/月Data — $99/月读 API120/分钟且2,000/天 每 IP1,200/分钟 每密钥6,000/分钟 每密钥Feed保存的过滤 webhook—是是变更拉取/changes?cursor—是是Feed 快照文件——是公司数据是同现状是是商业许可—是是文档对这张表补充了四条约束性说明站点支持的每一个搜索过滤器都能用在 Feed 上两个付费档都可以。未挂载到有效 API 订阅上的密钥等同于没有密钥。已验证的搜索引擎机器人豁免免费额度见限流一节。每档的 Feed 数量是配置值而非设计决策初始为 1$29/ 10$99。三、组件一Stripe 权益模型已落地3.1 为什么必须是独立产品而不是第三个套餐设计稿指出一个具体的破坏点Stripe 结账逻辑中的decideCheckoutTarget会把任何已存在的订阅视为就地变更的对象所以一个已有 Pro 的用户购买 API 时Pro 会被升级成 API用户花了钱却丢了原有套餐。源码印证了这个风险与修复方式。checkoutFamily会根据请求的 price 判断它属于哪个产品族只在该族内部选择可变更的订阅func (c Config) checkoutFamily(priceID string) (a, b []string) { if c.isAPIPrice(priceID) { return c.APIPrices, c.APIDataPrices } return c.Prices, c.UltraPrices }见 entitlement.go。其中的注释直接说明了动机没有它API 产品就会成为又一个可以就地升级的 price而购买 API 访问权的 Pro 用户会把他们的 Pro 订阅改成它——为了买一个并列的东西而丢掉正在付费的套餐。相关的产品族行为在 api_product_test.go 中有对应用例entitlementFrom从同一次 Stripe 重读中解析全部四个层级Pro/Ultra API/DataAPI 订阅不授予任何 plan 层级Pro 也不授予任何 API 层级。Pro 订阅者买 API → 打开新的 Checkout Session不动 Pro。API 订阅者买 Pro → 打开新的 Checkout Session不动 API。API 订阅者升级 Data →就地变更同一个订阅的 price不新开第二个 API 订阅。未配置的 price → 请求被拒绝什么都不打开。3.2 环境变量与配置设计稿写明两个配置项实际实现位于 config.goSTRIPE_API_PRICE_IDS # starter 档逗号分隔第一个是实际售卖项 STRIPE_API_DATA_PRICE_IDS # data 档.env.example中的注释同样确认STRIPE_API_PRICE_IDS为逗号分隔列表第一项就是售卖的 price。而Sells方法需要同时接纳四个价格列表Pro、Ultra、API、Data否则未配置的 price 会被拒绝。3.3 数据列与层级解析设计稿提出users.api_tier | api | data与users.api_until两列由既有 webhook 应用路径与cmd/billing-sync对账写入并提供一个类似plan.TierOf的解析函数apiplan.TierOf(user, now)。实现阶段做了简化这一点值得单独说明因为它是设计稿与代码的重要差异实际迁移是 0184_users_api_entitlement.sql新增两个可空时间戳列users.api_until_stripe、users.api_data_until_stripe。设计稿中的api_tier枚举列并未创建——层级是由两个时间戳推导出来的避免同一事实存两份。解析函数落在billing包而非独立的apiplan包因为计费拥有这个产品func APITierOf(apiUntil, dataUntil, now time.Time) APITier { switch { case dataUntil.After(now): return APITierData case apiUntil.After(now): return APITierAPI default: return APITierNone } }见 apitier.go。层级值为无、api、api_data。这两列与pro_until_stripe/ultra_until_stripe在同一条 UPDATE中写入这是正确性属性而非整洁性plan.sql 中的SetStripeEntitlement注释说明若拆成两条语句升级中的订阅者会出现Pro 已被清空、Ultra 尚未写入的时间窗落在该窗口内的请求都能看到。迁移注释还解释了为何没有_granted/_revenuecat来源列Stripe 是唯一卖家因此不需要多来源合成也不需要生成列billing-sync的到期窗口查询扩展到了两个新列即可。一个可验证的推论由于层级是按当前时间比较时间戳得出的订阅到期在读取瞬间即刻生效不依赖任何后台任务把状态改成过期。四、组件二限流已落地这是整套设计中实现最精巧的部分也是唯一一个对免费用户有破坏性变更的部分。4.1 四类调用者的判定顺序设计稿要求在每个公开读路由上OptionalAuth必须在限流器之前运行当时/jobs/search上是反过来的导致持密钥的调用方仍按 IP 计数。实现走了另一条路并给出了理由。没有把OptionalAuth前移而是让限流网关自己解析密钥。原因记录在 public_read_limit.go 与public_gate.go顶部在最热的读路径上、以爬虫为主要流量的主机上把数据库查询放在限流之前而且OptionalAuth在密钥查询失败时会返回 503此外它每次请求都会更新last_used_at等于在最热路径上写库。最终的判定顺序public_gate.go可信对端trusted peer→ 直接放行自身 SSR 前端经 loopback 访问否则全站共用一份预算。已验证机器人→ 直接放行。付费 full-scope 密钥→ 按账号计数使用该层级速率。其他所有情况→ 匿名规则。4.2 预算常量const ( anonPerMinute 120 anonPerDay 2000 paidAPIPerMinute 1200 paidDataPerMinute 6000 )见 public_gate.go。注释保留了定量依据每日上限才是拦住镜像的关键每分钟上限则设在skill 突发会退化为 Retry-After 而不是直接崩的位置。4.3 匿名双预算GCRA 的两步检查匿名调用者同时受每分钟与每日两个预算约束二者在所有公开读路由与 agent-search 路由之间共享——所以切换路由不会让额度翻倍。顺序上有个容易被忽略的细节先查分钟再查天。因为 GCRA 只对放行的请求计费若先查天一个突发超过分钟上限的 skill 会在几分钟内把每日额度耗在拒绝上。放行时响应头报告的是分钟预算客户端真正用来调速、也最先拒绝它的那个数。minuteKey, dayKey : anon:ip, anonday:ip minute, minuteKnown : ratelimit.Check(c, g.throttler, minuteKey, anonPerMinute, time.Minute) if minuteKnown !minute.Allowed { return ratelimit.Apply(c, minute, minuteKey, time.Minute) } if day, dayKnown : ratelimit.Check(c, g.throttler, dayKey, anonPerDay, 24*time.Hour); dayKnown !day.Allowed { return ratelimit.Apply(c, day, dayKey, 24*time.Hour) } if minuteKnown { return ratelimit.Apply(c, minute, minuteKey, time.Minute) }见 public_gate.go。4.4 密钥解析的防御性设计这是全篇最能体现工程权衡的部分。密钥解析只在请求确实携带 Bearer 密钥时发生因此匿名流量不承担任何新增成本。解析路径上叠了五层防护机制值作用密钥形态校验auth.WellFormedAPIKey形状不符的字符串不触发查询进程内 TTL 缓存60skeyTierTTL缓存结果含无层级与未知密钥单飞singleflight按 hash 去重忙密钥缓存过期时并发请求共享一次读单次查询超时150mskeyLookupTimeout慢库降级为匿名不阻塞请求每 IP 未命中查询预算30/分钟keyLookupsPerMinute伪造密钥洪泛不能变成查询洪泛另有两点值得注意未命中缓存与命中缓存分离伪造密钥的缓存大小由攻击者决定所以misses有上限missCacheMax 10_000超限后整体重置——代价只是多几次已受限的查询真实密钥存放在另一个 map 中大小由真实密钥数量决定伪造洪泛永远无法驱逐它们。查询错误不缓存数据库故障不能让一个付费密钥在恢复后仍被钉在匿名速率上一分钟。层级每次请求重新解析缓存只缓存时间戳层级由APITierOf现场计算所以订阅在缓存条目生命周期内到期会立即被看到被延迟的只是账号发生了变更。失败语义统一为fail-open 到更便宜的层级任何解析失败都把请求当作匿名处理而不是 503。4.5 embed 密钥永远拿不到付费速率只有fullscope 的密钥计入付费速率。原因是安全边界embedscope 的密钥按设计会发布在第三方页面里如果它能换到付费速率任何人复制一份就白拿了。同理账号没有 API 层级时其 full-scope 密钥也按匿名处理。4.6 验证机器人Cloudflare Transform Rule 共享密钥Cloudflare 在cf.client.bot为真时通过 Transform Rule 注入X-FH-Verified-Bot: secret。源站监听在 0.0.0.0:443任何直连调用方都能伪造任意请求头因此该头只有值等于配置的密钥时才被信任且比较必须是常量时间func (g *publicGate) verifiedBot(c *fiber.Ctx) bool { if g.cfg.BotSecret { return false } return subtle.ConstantTimeCompare([]byte(c.Get(verifiedBotHeader)), []byte(g.cfg.BotSecret)) 1 }见 public_gate.go。密钥未配置时豁免完全关闭。文档还提示了一个配额层面的细节该 zone 在 Cloudflare Free 上5 条自定义 WAF 规则已用完而 Transform Rule 走http_request_late_transform这个独立入口、有自己的配额不占用 WAF 规则数——但添加前应确认计数。4.7 灰度开关与 429 语义整个匿名收紧由一个开关控制PUBLIC_ANON_LIMITS。未设置 → 完全保留今天的匿名预算读 600/分钟、agent search 300/分钟各自独立的 IP key无每日上限。strict→ 应用共享的 120/分钟 2,000/天。其他任何值 →拒绝启动。原因写在 config.go一个拼写错误在此处看起来与故意保留旧限制完全一样因此必须启动即失败。// StrictAnonLimits reports PUBLIC_ANON_LIMITSstrict, the one value besides empty it accepts. func (s Settings) StrictAnonLimits() bool { return s.PublicAnonLimits strict }429 响应保留Retry-After与X-RateLimit-*并且只有匿名调用者会被告知升级路径——付费用户被拒绝时已经持有密钥再提示请使用密钥读起来像 bugif tier billing.APITierNone isTooManyRequests(err) g.cfg.PricingURL ! { return fiber.NewError(fiber.StatusTooManyRequests, too many requests, please try again later — for higher limits use an API key: g.cfg.PricingURL) }Retry-After的取整规则在 throttler.go 中向上取整且下限为 1因为 1.7 秒若报成 1 会让客户端提前重试撞回刚被拒绝的那个窗口。4.8 三个例外suggest、Talent 目录、MCPsuggest1,200/分钟与公开 Talent 目录120/分钟保持各自预算不变。前者按调用频率切分搜索框每次停顿发一次请求与普通读共享预算会导致打字把回答页自己限流后者按读的是什么切分唯一返回人的公开读接口目录小而完整成员同意的是被找到而不是被枚举进别人的数据库。MCP 表面/mcp、/mcp/account走chatAgentLimiter在 strict 下仍只保留 agent-search 的每分钟预算。原因是这些请求来自 OpenAI 的共享出口地址一个按 IP 的每日上限会变成整条渠道的上限。推论strict 下 OJCP manifest不声明匿名速率因为 2,000/天没有诚实的每秒换算。五、组件三Feed 过滤订阅设计阶段设计稿定义表api_feedsid, user_id, name, query, webhook_url, webhook_secret, cursor_ts, cursor_id, created_at, paused_at, failures其中query就是保存搜索所存的同一条规范查询串由search.FilterFromValues解析——这是整套设计的一个核心不变量过滤语义只有一份实现。两条创建途径UI 上/jobs过滤器旁的 Make API feed 动作或POST /api/v1/feeds。两条约束Feed 必须收窄目录或者所有者必须在 data 档search.Narrows。否则全目录 Feed就是白拿全量数据的后门。Webhook URL 必须经过safehttp禁止私有地址保存时发送一次测试投递。说明截至当前仓库状态api_feeds表、/api/v1/feeds路由与cmd/feed-deliver尚未在代码中出现本节描述的是设计稿定义的契约。六、组件四变更事件——为什么要两个索引这是设计稿中技术含量最高的一节。6.1 三种事件所有事件都携带职位的id、public_slug和一个seq游标added/updated— 完整的公开职位对象jobview外加company_slug。removed—{id, public_slug, reason: closed|duplicate, at}。6.2 added/updated 从哪来updated_ts绝不能取索引时间职位索引新增一个可过滤可排序属性updated_ts取自jobs.updated_at——不是索引写入时间。理由直白全量重建每几小时跑一次若用索引时间每次重建都会让所有职位看起来刚更新过。Feed 的查询是feed filter AND updated_ts cursor_ts按(updated_ts, id)排序。因为search-drain推送有延迟每次查询要重读 30 分钟的重叠窗口事件在(id, updated_ts)上幂等这一点必须明确告知消费方。added与updated的区别由读取时的created_at cursor判定客户端必须把两者都当作 upsert 处理因为重叠窗口内同一条记录可能被重复投递。6.3 removed 从哪来一个只活 7 天的瘦索引关闭或去重抑制的职位已经不在职位索引里所以无法在职位索引上求值 Feed 过滤器。于是有第二个索引jobs_removed保存瘦文档id、public_slug、每一个可过滤属性、removed_ts、reason保留 7 天。三个写入来源search_delete_outbox的 drain 在从jobs删除之前写入瘦文档全量重建会静默丢弃被去重抑制和新近不符合条件的职位因此对旧索引中存在、新索引中缺失的每个 id 都写一条瘦文档每日任务删除removed_ts now - 7d。关键点jobs_removed使用完全相同的可过滤属性列表和同一个FilterFromValues。因此一个 Feed 的过滤器是一条规则在两个索引上求值绝不在 Go 里重新实现一遍。推论超过 7 天未同步的消费方会被要求重新做快照——变更端点返回410并附该指令。七、组件五投递Webhook 与拉取共用游标读取器cmd/feed-deliver每 2 分钟运行一次以Typeoneshot方式。对每个活跃 Feed从两个索引读取游标之后的最多500 条事件POST 为 JSON签名头X-FH-Signature: sha256HMAC(webhook_secret, body)并带上批次最后一条的seq收到 2xx 则推进游标否则保持游标、退避重试连续 N 次失败后暂停 Feed 并通过邮件通知所有者。GET /api/v1/feeds/{id}/changes?cursor返回同样的批次服务于拉取型消费方与故障后的追赶。拉取与推送共用同一个游标读取器推送只是额外多存了一次游标。八、组件六快照文件data 档POST /api/v1/feeds/{id}/snapshot启动构建GET返回状态与下载 URL。Worker 用keyset 分页id last按 id 排序遍历职位索引以绕过 Meilisearch 的分页窗口上限写入 gzip 压缩的 JSONL 到对象存储并在文件里盖上变更流的起始seq。限制每个 Feed 每天最多一次构建。快照的seq标记是设计与实现联动的关键——一个快照文件加上从那一点开始的变更流才是完整的同步协议。九、组件七许可与文档已落地9.1 数据许可的四个条款条款页新增 Data licence for API plans 一节terms/page.svelte实际措辞包含四条账号持有有效 API 计划期间可以存储通过 API 读到的数据并在自己的产品中展示包括商业产品不得转售或再分发原始数据——无论是下载、数据集还是以把职位转交给其他服务为目的的 API 或 Feed展示的每一条职位必须链接到它在 freehire.me 的页面或带有 via freehire.me 署名计划终止时许可同时终止停止展示并在 30 天内删除已存储内容无密钥使用仅限个人非商业用途。同时声明职位本身仍属于发布它们的雇主这些条款不授予雇主或原始来源没有给过我们的权利。9.2 OpenAPI 与 /pricingopenapi.yaml增加bearerAuth安全方案在公开读上可选、anon 与 paid 的额度说明、Feed 端点与变更事件 schema。/pricing新增 API 区块两个计划与结账链接/my/api-keys展示账号的 API 层级与速率或指向购买入口。一个/api落地页含分层、限额与三步快速开始创建 Feed → 快照或翻页 → Webhook。ApiPlans.svelte中的文案直接从limits渲染这个limits来自GET /plans的api_limits而后者读取的是与限流器同一批常量func apiLimitsFor(strict bool) apiLimitsView { v : apiLimitsView{AnonymousPerMinute: publicReadsPerMinute, APIPerMinute: paidAPIPerMinute, DataPerMinute: paidDataPerMinute} if strict { v.AnonymousPerMinute, v.AnonymousPerDay anonPerMinute, anonPerDay } return v }见 plans_public.go。注释说明了为什么不能硬编码定价页上的承诺是客户可以拿来说事的一旦数字漂移静默出错的地方恰好最贵。这也是为什么未启用 strict 时anonymous_per_day为0——页面据此不显示每天字样。Data 档还有一个诚实性要求它在批量文件尚不存在时就开卖页面必须写coming soon而不是暗示已包含。ApiPlans.svelte的 perks 里正是Bulk catalogue files — coming soon。十、上线顺序与灰度设计稿给出三步每一步是独立的 plan 与 PR权益 密钥感知限流 匿名限额 验证机器人 许可——可以单独售卖。Feed、updated_ts、jobs_removed、变更拉取、Webhook 投递。快照文件与 $99 档。实际的灰度动作顺序见design.md的 D6 与 tasks部署代码 → 创建 Stripe 产品与价格 → 设置 price 环境变量 → 检查 /pricing → 添加 Cloudflare Transform Rule 与 secret → 设置 PUBLIC_ANON_LIMITSstrict回滚方式就是取消该环境变量新增的两列是惰性的无回填、无锁ADD COLUMN在 PostgreSQL 11 只改目录。任务清单 tasks.md 显示权益存储、计费、限流、Web、文档五组任务已完成上线只差上游来源条款核查、CI 合并与运维清单其中 6.1 与 6.3 仍未勾选。十一、风险清单与需要提前验证的假设设计稿列出的风险中有几条具有普遍参考价值updated_ts是先设置后发布二进制的变更需要一次全量重建——可过滤/可排序属性属于索引设置改设置必须先于新代码生效。backfill-derive是否会更新jobs.updated_at若会每次回填都会为所有被触及的职位发出updated事件可接受但 webhook 量会尖峰若不会字典修复带来的 facet 变更就永远到不了 Feed。必须在第 2 步之前验证。全目录 Feed 每天约 60 万事件投递必须批量且慢消费方不能拖累其他消费方因此需要 per-feed 游标与每次运行的有界批次。许可与上游来源条款的冲突Adzuna 等聚合源可能禁止转授其职位必须在第 1 步上线前核查必要时在许可文本中明确排除这些来源。非 IT 职位也在目录中除非过滤器写了is_techFeed 会包含它们。设计稿未决、design.md中记录为 open question 的还有API 产品的折扣促销与邀请折扣会应用到任意售卖 price且收取的 API 发票会结算推荐奖励、以及同一出口地址后的访客——SPA 从浏览器调用公开 APIstrict 下一个办公室 NAT 会共享 2,000/天因此在开启 strict 前应从 access log 测量每 IP 的页面发起调用量。另有一条实现层面的取舍值得记住被撤销的密钥或到期的层级最长保留其付费速率 60 秒缓存 TTL这是有界且被文档承认的延迟。十二、给集成方的最小实践curl https://freehire.me/api/v1/jobs/search?countriesdecategorysoftware_engineeringlimit100 \ -H Authorization: Bearer $FREEHIRE_API_KEY示例取自 JobsApiLandingView.svelte。集成方应遵循文档化的三条纪律读X-RateLimit-Remaining主动降速不要靠撞 429 来发现天花板。它是漏桶而非计数器可能比你的请求数落后一应当作用来调速的读数而非精确账本。响应缺失X-RateLimit-*意味着未知不是无限——那是限流器未能运行而放行了请求。变更流按 upsert 处理并在断线超过 7 天后重新拉取快照因为removed事件只保留 7 天。结语这份设计稿的价值不在加了一个付费档而在它把三件事想清楚了产品边界API 不是 Pro 的变体而是独立产品族结账与权益都不许互相覆写、成本边界限流按调用者类别而非按路由密钥解析只在必要时发生并全程失败降级、语义边界过滤器只实现一次在职位索引与 7 天删除索引上同样求值变更流用updated_at而非索引时间并用快照seq接续。落地时对设计稿的两处简化——用两个可空时间戳替代api_tier枚举列、把apiplan.TierOf收进billing.APITierOf——都遵循同一取向同一事实只存一份产品归属由卖出它的那个包决定。这也是后续实现 Feed、变更事件与快照时最值得沿用的判断标准。赞分享【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐Ghost Stripe Service 源码解析Stripe API 封装、Webhook 处理与订阅计费流程Ghost Stripe Service 源码解析Stripe API 封装、Webhook 处理与订阅计费流程 本篇技术指南聚焦 Ghost独立发布平台CMS后端前端ruoyi-vue-pro商业化付费功能与订阅模式ruoyi vue pro商业化付费功能与订阅模式 痛点开源项目如何实现可持续发展 你还在为开源项目的商业化发愁吗ruoyi vue pro作为一款功能后端前端低代码认证鉴权ChatHub商业模型分析免费增值与订阅服务设计ChatHub商业模型分析免费增值与订阅服务设计 ChatHub作为一款 All in one聊天机器人客户端 成功采用了经典的免费增值FreemiumAI 应用前端上一篇Media Extended将Obsidian打造成终极视频笔记工具的三步快速指南下一篇Sionna通信系统仿真库从理论到实践的完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑