Openship Cloud 定价目录(Pricing Catalog)解析:以pricing.json为唯一事实源的价格、限额与折扣体系
【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship
导读
Openship 是一个自托管部署平台(Self-hosted deployment platform),其云服务(Openship Cloud)的每一笔价格、每一项额度、每一条限制,都集中存放在packages/core/src/pricing/pricing.json这一个文件里,配套的文案则按语言拆分存放在packages/core/src/pricing/locales/目录。本文以 定价目录 README 为主线,结合源码、校验器、解析器与测试用例,完整讲解这一“单一事实源”设计:价格与限额如何定义、如何被 API/仪表盘/营销站三方一致消费、如何改动价格与限额、如何通过“派生而非编写”的方式保证页面数字与底层强制执行(Oblien 配额、plan-guard 门禁)永不漂移,以及如何安全地运行限时折扣活动。读完你将掌握 Openship 定价体系的全貌,并能安全地修改价格、限额、文案与促销活动。
定价目录是什么:一个 JSON 文件,三个消费面
Openship 的定价不写在 TypeScript 里。所有价格、额度、限制与功能清单都在pricing.json中,而描述它们的文案在locales/下按语言各一个文件(en/ar/de/es/fr/ja/pt/tr/zh 共九种)。这样做的直接收益是:发布一次价格调整 = 一次 JSON 编辑 + 一次测试运行,无需阅读任何业务代码。
这个目录被三个界面读取,且保证彼此一致:
| 消费面 | 读取方式 |
|---|---|
API —GET /api/billing/plans | resolvePlans(locale),按调用方语言返回 |
| 仪表盘 — Billing → Plans | 通过网络传输同一个 payload |
营销站 —openship.io/pricing | 直接以 server component 方式import @repo/core |
执行侧(构建分钟、免费子域名、静态-only 限制)读取的是planLimits(tier),绝不读取本地化视图——限制是数字,不应随请求抵达时的语言而改变。
目录文件一览
| 路径 | 作用 |
|---|---|
pricing.json | 编辑这个——价格、额度、限制、功能顺序 |
locales/en.json | 编辑这个——英文文案,其他语言的源事实 |
locales/<lang>.json | 翻译文件,缺失的 key 回退到英文;测试要求不缺失任何 key |
schema.ts | 校验器,在解析期就拒绝不自洽的编辑 |
index.ts | 解析逻辑——本地化套餐、限额查询、Stripe price-id 解析 |
pricing.test.ts | 护栏,每次编辑后都要运行 |
其中index.ts在模块加载时对pricing.json执行pricingCatalogSchema.parse(rawCatalog),校验失败直接throw(index.ts)。这是因为目录是已提交的受信文件而非用户输入,一次格式错误的编辑必须在 CI 和本地构建期暴露,而不是让价格在 checkout 调用里悄悄变成undefined(schema.ts 的头部注释明确阐述了这一取舍)。
修改价格:从 JSON 到 Stripe 的完整链路
README 给出的改价三步是:
- 修改
pricing.json中price.monthly(单位是USD 美分——3900即 $39); - 在 Stripe 创建对应价格,并把套餐在
stripePriceEnv.monthly中命名的环境变量设置到 SaaS 上(如STRIPE_PRICE_PRO_MONTHLY=price_1Ab…); - 在
packages/core下运行bunx vitest run src/pricing。
为什么存环境变量名而不是 id
目录中存储的是环境变量的名字,绝不是一个真实的 Stripe id。原因写在了 schema.ts 的注释里:pricing.json会被打进浏览器 bundle,若在模块加载期读取process.env.STRIPE_*,既会把服务端关注点打进客户端包,又会在构建时把值冻结住。因此resolveStripePriceId()在调用时(服务端)才读取环境变量(index.ts),测试pricing.test.ts也验证了“调用时读取、未设置或空白时返回 null 而非占位符”。
忘配 Stripe id 会发生什么
如果你发布了价格却忘记配 Stripe id,两件事会发生:
- 启动时日志告警:
validatePlanPriceIds()(index.ts)遍历所有可购买价格与充值包,收集缺失项。在apps/api/src/app.ts的 boot 检查中,云模式(CLOUD_MODE)下作为FATAL 错误输出,自托管模式仅作为信息记录。 - 使用时 503 拒绝:checkout 走到
createCheckoutSession时,若resolveStripePriceId返回 null,会抛出503 BILLING_NOT_CONFIGURED(billing.service.ts)。
Boot故意不致命:为一个未设置的价格 id 拒绝启动整个 SaaS,等于拿一个坏掉的按钮换一次全站宕机。这是“响亮但非致命”的工程取舍。
修改限额:null= 无限,以及阶梯的差异化原则
所有数字型限额统一约定null= 无限(unlimited),全目录无例外。schema 中limitNumber被定义为非负整数且可空(schema.ts)。
pricing.json中一个典型套餐的 limits 结构(以 free 为例,含注解):
"limits": { "workloads": ["static"], // WorkloadType[] — "static" | "web" | "worker" "services": false, // Compose 栈、目录应用、托管数据库 "runningServices": 0, // 并发服务数——每个占用一个 Oblien workspace "maxProjects": 3, "maxResourceTier": "low", // 单服务最大机型,用向导自己的 tier 命名 "computeMinutesPerMonth": 0, // 应用运行时,以 low 机器分钟计(0 = 静态-only 档) "buildMinutesPerMonth": 500, "freeSubdomains": 10, // *.opsh.io 路由数 "customDomains": null, "seats": null // 所有档位都是 null——Openship 从不按席位收费 }档位之间靠什么区分
只有五个数字加一个支持级别:计算分钟、构建分钟、机型大小、项目数、运行服务数。除此之外没有任何区别——上层档位不会获得下层没有的“能力”,唯一例外是 free 档的静态-only,且该边界由workloads+services两个字段强制执行。
这是一条关于诚实的规则而非品味问题。README 明确记录了历史教训:过去用功能列表做差异化,但那些功能从未被真正执行——Pro 卖过“内置邮件服务器”,而mail.controller.ts在CLOUD_MODE下对所有邮件路由返回 404;Starter 卖过“每次推送的预览部署”,该功能根本没实现;Scale 卖过“延长保留期的审计日志”,而每个档位本来就有无门禁的审计日志。现在,任何档位都成立的功能只出现在standard.features一次,且只有真正在云上交付的才会列进去。
两条测试守住了这条线:"differentiates tiers on usage and size, not on capability"(pricing.test.ts)与"retires the copy for capabilities cloud does not sell"(pricing.test.ts)——后者显式点名mailServer与previewDeploys两个 key 必须保持退役状态。
计算分钟(Compute minutes)的定义
1 个计算分钟 =low机器运行 1 分钟。更大的机器按倍数消耗,倍数由computeUnitsPerMinute()从RESOURCE_TIER_SPECS派生(index.ts):medium2×、high4×、xlarge8×。这样一个对外发布的额度就能覆盖所有机型,无需为每种机型单独报价。
机型表本身定义在packages/core/src/resources.ts:micro0.25 vCPU/256 MB、low0.5 vCPU/512 MB、medium1 vCPU/1 GB、high2 vCPU/2 GB、xlarge4 vCPU/8 GB。注意倍数派生自这张表而非手写常量——如果将来high被重新规格化,计费倍数会自动跟随,不会出现页面写着 4× 而实际机器已变的漂移(pricing.test.ts 用"charges bigger machines proportionally more per minute"锁定了这一点)。
每个档位包含的计算量都足以让该档位的全部应用 24/7 运行:一个月是 43,200 分钟,Scale 的 50 个应用需要 2,160,000 分钟,而它发布的是 2,200,000。测试"includes enough compute to run a tier's whole app cap around the clock"(pricing.test.ts)强制执行这条约束。README 的告诫很直白:在一个只够跑三个应用的预算旁边写“50 个应用”正是旧信用点数字的毛病,不要通过只提高应用上限而不提高分钟数的方式让它复活。
为什么构建分钟(Build minutes)给得大方
构建分钟几乎不花钱,却是客户对比时最在意的数字。README 给出的成本模型:1 个构建分钟 = 4 个 vCPU-分钟,按市价约$0.0005;而 Vercel 对同样的 4 vCPU / 8 GB 标准构建机按$0.014/分钟计量,约30× 加价。其 Pro 套餐 $20/席位含 $20 额度,客户把全部额度花在构建上约得 1,428 分钟(约每美元 71 分钟);而 Openship Starter 用 $10 提供 3,000 分钟(每美元 300 分钟)。结论写得很直接:在构建上抠门只省几分钱却输掉对比,如果只能调一个数,把构建分钟往上调。
Oblien 信用点授予:派生而非编写
这套体系取代了旧的credits手写数据块。旧数字(500/2k/10k/60k)没有任何可度量的含义——Scale 宣传 50 个运行服务,预算却连一个应用的整月运行都撑不起。现在 Oblien 的授予额由发布的两个额度派生:
planMonthlyCredits() = (computeMinutesPerMonth + buildMinutesPerMonth × buildMultiplier) × 1000(index.ts,其中buildMultiplier是构建机 vCPU 与low机 vCPU 之比。)
构建分钟进入这个求和是承重设计而非整洁性考虑:toOblienCredits()拒绝非正配额,而 free 档合法地发布 0 计算分钟——若只按计算量派生,每次 free 档的配额推送都会 throw。测试"grants every finite tier a POSITIVE quota, free included"(pricing.test.ts)会在任何人把构建项“简化”掉时失败。
充值包(Top-up packs)则仍是手写信用点(creditsMilli,其 Stripe price id 保持不动),只是显示为计算分钟——1 计算分钟 ≡ 1 信用点,因此数字完全相同。resolveCreditPacks()会把每个包换算成“≈ N 小时的小应用,或 M 个构建分钟”这样客户能感知的解释(index.ts),因为“+25,000 计算分钟”这种话说了等于没说。
目录存在的核心规则:绝不发布一个没有任何东西执行它的数字
README 反复强调这条规则。目录里每个限额要么由 Oblien 执行(resource_limits、信用点配额),要么由apps/api/src/lib/plan-guard.ts的门禁执行。曾经存在的bandwidthGb限额,Oblien 根本没有带宽上限来执行它,于是被删除而不是留作装饰。测试"publishes no limit that nothing can enforce"(pricing.test.ts)将限额 key 集合与实际能拒绝请求的东西逐一比对。限额到执行点的映射如下:
| 限额 | 执行者 |
|---|---|
workloads | plan-guard:assertPlanAllowsDeployShape |
services | plan-guard:assertPlanAllowsServices |
runningServices | Oblienmax_workspaces+ plan-guard |
maxProjects | plan-guard:assertProjectQuota |
maxResourceTier | Oblienmax_vcpus/max_ram_mb/max_disk_gb |
computeMinutesPerMonth | Oblien 信用点配额(经planMonthlyCredits()) |
buildMinutesPerMonth | plan-guard:assertBuildMinutesAvailable |
freeSubdomains | plan-guard:assertFreeSubdomainQuota |
customDomains/seats | 处处为 null = 无限,无需执行 |
plan-guard.ts是整个云上“限额变成拒绝”的唯一地点:每个限额都从planLimits(tier)读取、绝不硬编码,因此客户在定价页看到的数字就是拒绝他的那个数字。拒绝以402 PLAN_UPGRADE_REQUIRED抛出,并携带机器可读的reason(static-only、build-minutes-exhausted、free-subdomain-limit、resource-tier、running-services、project-limit),让客户端能挑选正确的升级 CTA(plan-guard.ts)。其作用域刻意限定env.CLOUD_MODE——自托管 Openship 免费且不计量,这是产品承诺。
为什么 Oblien 上限是派生的而非手写
Oblien 每个 namespace 接收{max_workspaces, max_vcpus, max_ram_mb, max_disk_gb},其中四个里的三个是“每 workspace”上限——只有max_workspaces是 namespace 级。直接手写它们会得到“Pro: 16 vCPU”的页面文案,实际却允许 16 × 10 = 160 vCPU,且这个数字与部署向导可选机型完全对不上。因此toOblienLimits()(index.ts)这样派生:
| Oblien 字段 | 派生自 |
|---|---|
max_workspaces | runningServices+oblien.buildWorkspaceHeadroom |
max_vcpus/max_ram_mb/max_disk_gb | 档位maxResourceTier规格与oblien.buildResources的max |
那个 max 是承重的:构建有自己独立的 workspace,若上限低于构建机规格,Oblien 会对每一次构建返回 409。历史上 free 档发布的是 2 vCPU / 2 GB,而构建机是 4 vCPU / 8 GB——上限一旦上线,free 档唯一允许运行的负载(静态部署)会在第一天全部失败。测试"derives Oblien ceilings that can always fit a BUILD workspace"(pricing.test.ts)锁死了这一点。
buildWorkspaceHeadroom(当前为 2,见 pricing.json)表示在运行服务之上允许的临时构建 workspace 数量——Oblien 把构建 workspace 也算进max_workspaces,没有这个余量,一个用满服务数上限的用户将永远无法部署。
一个值得理解的后果:各档位上限趋同不是 bug
因为构建机占主导,max_vcpus/max_ram_mb在每个档位都会得出相同结果。Oblien 无法区分构建 workspace 与运行 workspace,它物理上不可能同时“容得下构建”又“限制住服务”。Oblien 是粗粒度兜底,真正的单服务尺寸上限由assertPlanAllowsResourceTier在选机型的现场执行(plan-guard.ts)——maxResourceTier用向导自己的RESOURCE_TIER_ORDER顺序比较,custom机型则按数值与档位规格比较。
运行限时折扣活动(Discount Campaign)
campaigns[]存放限时自动折扣——无需写代码,自动对所有人生效。一个完整条目:
{ "id": "launch50", "percentOff": 50, "appliesTo": "all", // 或 ["pro","team"] "startsAt": "2026-09-01T00:00:00Z", "endsAt": "2026-09-30T23:59:59Z", // 必须带 offset 的完整 ISO 时刻 "durationMonths": 3, // null = 订阅存续期 "stripeCouponEnv": "STRIPE_COUPON_LAUNCH50" }操作步骤:
- 在 Stripe 创建优惠券(
percent_off必须等于percentOff,时长必须匹配durationMonths),并设置 campaign 命名的环境变量; - 把条目加进
pricing.json,运行测试。
schema 与启动检查已经替你挡住的坑
- 裸日期
"2026-09-30"(UTC 与本地时间有歧义,且会提前一天结束)——isoInstant正则要求完整 ISO-8601 时刻带 offset(schema.ts); - 两个 campaign 在同一套餐上时间重叠(避免解析器静默选第一个,页面与 Stripe 优惠券对不上);
- 窗口在开始前就结束;
- 指向不存在的套餐;
- 启动时经
verifyCampaigns()发现目录说 50% 而优惠券只给 40%(billing.service.ts)——目录的percentOff是显示,Stripe 的coupon.percent_off才是钱,除了一次比对没有任何结构性力量能保证两者相等,所以每次启动都要核对。
两个必须知道的行为
- 活动期间优惠码输入框会消失。Stripe 拒绝同一 Checkout Session 同时携带自动折扣与可兑换码字段,因此
apps/api/scripts/promo-code.ts铸造的优惠码在活动期间无法兑换。先运行promo-code.ts list查看存量码,并让活动至少与任何未兑优惠码同样慷慨(schema.ts)。优惠码 CLI 以 Stripe 为事实源,用法为bun --cwd apps/api scripts/promo-code.ts create --percent 20 --code LAUNCH20、list、show、revoke(promo-code.ts)。 now永远作为参数传入。activeCampaign(planId, now)与effectiveMonthlyPrice(planId, now)从不读取模块级时钟(index.ts)。因为该文件会被打进浏览器 bundle 和预渲染页面,任何在模块作用域求值的东西都会在构建时冻结、永不失效。effectiveMonthlyPrice按 Stripe 对百分比优惠券的舍入方式(半进位到最小货币单位)计算,保证页面显示与实收一致。
修改文案:Key 引用与占位符插值
plans.<id>.name/.tagline以及features.*字符串都在locales/<lang>.json中。功能列表项通过key从pricing.json#plans[].features引用,数组顺序即显示顺序——因此调整或删除一个条目是改pricing.json,而改措辞是改 locale 文件。
功能字符串支持从该套餐自身限额解析的{placeholders}插值,让一个数字在pricing.json里只声明一次、所有语言自动拾取:
{computeMinutes}{buildMinutes}{runningServices}{maxProjects}{freeSubdomains}{customDomains}{seats}{powerCpu}{powerRamGb}{powerDiskGb}{inherited}{freeDomainSuffix}
(完整占位符表见 index.ts,其中机型相关数字同样派生自RESOURCE_TIER_SPECS,保证定价页引用的机型就是向导里真实可选的机型——历史上页面宣传 64 vCPU 而选择器最高只有 2 的教训。)
数字按读者 locale 格式化(60,000/60.000),阿拉伯语被钉在拉丁数字以匹配产品其余部分(NUMBER_LOCALE映射ar-u-nu-latn,index.ts)。fill()对未知占位符原样保留,因此翻译里的拼写错误会在评审时以{buildMinutes}字面量形式暴露,而不是悄悄变空白。
新增语言
丢入locales/<code>.json,把代码加进index.ts的PRICING_LOCALES(index.ts),并保持与仪表盘的 locale 列表同步——测试会在这两者分叉时失败,因为“翻译过的仪表盘配英文价格”比两者任一都更糟。当前九种语言为["en","ar","de","es","fr","ja","pt","tr","zh"],测试"covers exactly the dashboard's 9 locales"(pricing.test.ts)锁死对齐。
测试在守护什么
除形状校验外,pricing.test.ts 还强制保证:
PlanTierId与目录 id 完全一致——新增档位而不更新类型联合就是编译错误;- 阶梯单调——更贵的档位任何额度都不得小于更便宜的档位("keeps prices monotonically increasing");
- 每个付费档每美元价值严格优于其下一档("gives each paid tier more credit per dollar than the one below");
- 任何档位不按席位收费(
seats全为 null); - 每次信用点授予低于 Oblien 的 10,000,000 信用点上限;
- 未知
plan_tier_id回退到最严格的档位(free)而非打开任何门禁("falls back to the most restrictive tier for an unknown plan id"); - 翻译侧:全 key 对齐、占位符集合一致、非英文 locale 不允许残留英文串(少数豁免如
SSO、SLA、品牌名、plan 名)。
小结:一套“数字诚实”的定价工程
Openship 的定价目录用“一个 JSON + 一份每语言文案 + 一个校验器 + 一套解析函数 + 一组测试”把定价这件事做成了可审计、可测试、防漂移的工程构件:页面数字、Stripe 实收、Oblien 配额三者由同一事实源派生,任何“发布一个没有任何东西执行它的数字”都会被测试在合并前拦住。对于需要运营多档位订阅与折扣活动的平台开发者,packages/core/src/pricing/是一个值得对照学习的范本——核心可复用的方法论有三条:用美分和明确定义的计量单位存储价格;用派生而非手写让页面与执行永不漂移;用null表示无限并把“无法执行”的字段直接删掉。
【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考