news 2026/9/15 12:35:58

Openship Cloud 定价目录(Pricing Catalog)解析:以 `pricing.json` 为唯一事实源的价格、限额与折扣体系

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Openship Cloud 定价目录(Pricing Catalog)解析:以 `pricing.json` 为唯一事实源的价格、限额与折扣体系

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/plansresolvePlans(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 给出的改价三步是:

  1. 修改pricing.jsonprice.monthly(单位是USD 美分——3900即 $39);
  2. 在 Stripe 创建对应价格,并把套餐在stripePriceEnv.monthly命名的环境变量设置到 SaaS 上(如STRIPE_PRICE_PRO_MONTHLY=price_1Ab…);
  3. 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.tsCLOUD_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)——后者显式点名mailServerpreviewDeploys两个 key 必须保持退役状态。

计算分钟(Compute minutes)的定义

1 个计算分钟 =low机器运行 1 分钟。更大的机器按倍数消耗,倍数由computeUnitsPerMinute()RESOURCE_TIER_SPECS派生(index.ts):medium2×、high4×、xlarge8×。这样一个对外发布的额度就能覆盖所有机型,无需为每种机型单独报价。

机型表本身定义在packages/core/src/resources.tsmicro0.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 集合与实际能拒绝请求的东西逐一比对。限额到执行点的映射如下:

限额执行者
workloadsplan-guard:assertPlanAllowsDeployShape
servicesplan-guard:assertPlanAllowsServices
runningServicesOblienmax_workspaces+ plan-guard
maxProjectsplan-guard:assertProjectQuota
maxResourceTierOblienmax_vcpus/max_ram_mb/max_disk_gb
computeMinutesPerMonthOblien 信用点配额(经planMonthlyCredits()
buildMinutesPerMonthplan-guard:assertBuildMinutesAvailable
freeSubdomainsplan-guard:assertFreeSubdomainQuota
customDomains/seats处处为 null = 无限,无需执行

plan-guard.ts是整个云上“限额变成拒绝”的唯一地点:每个限额都从planLimits(tier)读取、绝不硬编码,因此客户在定价页看到的数字就是拒绝他的那个数字。拒绝以402 PLAN_UPGRADE_REQUIRED抛出,并携带机器可读的reasonstatic-onlybuild-minutes-exhaustedfree-subdomain-limitresource-tierrunning-servicesproject-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_workspacesrunningServices+oblien.buildWorkspaceHeadroom
max_vcpus/max_ram_mb/max_disk_gb档位maxResourceTier规格与oblien.buildResourcesmax

那个 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" }

操作步骤:

  1. 在 Stripe 创建优惠券(percent_off必须等于percentOff,时长必须匹配durationMonths),并设置 campaign 命名的环境变量;
  2. 把条目加进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 LAUNCH20listshowrevoke(promo-code.ts)。
  • now永远作为参数传入activeCampaign(planId, now)effectiveMonthlyPrice(planId, now)从不读取模块级时钟(index.ts)。因为该文件会被打进浏览器 bundle 和预渲染页面,任何在模块作用域求值的东西都会在构建时冻结、永不失效。effectiveMonthlyPrice按 Stripe 对百分比优惠券的舍入方式(半进位到最小货币单位)计算,保证页面显示与实收一致。

修改文案:Key 引用与占位符插值

plans.<id>.name/.tagline以及features.*字符串都在locales/<lang>.json中。功能列表项通过keypricing.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.tsPRICING_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 不允许残留英文串(少数豁免如SSOSLA、品牌名、plan 名)。

小结:一套“数字诚实”的定价工程

Openship 的定价目录用“一个 JSON + 一份每语言文案 + 一个校验器 + 一套解析函数 + 一组测试”把定价这件事做成了可审计、可测试、防漂移的工程构件:页面数字、Stripe 实收、Oblien 配额三者由同一事实源派生,任何“发布一个没有任何东西执行它的数字”都会被测试在合并前拦住。对于需要运营多档位订阅与折扣活动的平台开发者,packages/core/src/pricing/是一个值得对照学习的范本——核心可复用的方法论有三条:用美分和明确定义的计量单位存储价格;用派生而非手写让页面与执行永不漂移;用null表示无限并把“无法执行”的字段直接删掉

【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 12:34:09

Linux日志体系实战:从故障排查到安全审计与事件复盘

日志这东西&#xff0c;平时没人惦记&#xff0c;真到出事儿的时候——服务器宕了、被人入侵了、业务半夜报警了——你才会发现它比命都重要。我干了这么多年运维和安全&#xff0c;见过太多同事一上来就 tail -f /var/log/messages 瞎翻&#xff0c;翻半天找不着重点&#xff…

作者头像 李华
网站建设 2026/9/15 12:31:40

document 对象属性详解:从 DOM 入口到实际开发应用

1. 先搞懂 document 对象在浏览器里的地位 1.1 document 对象到底是什么 我相信很多前端初学者第一次看到 document 对象&#xff0c;是在 console 里敲了一句 document.title。后来慢慢知道 document 是 window 下的一个属性&#xff0c;代表整个 HTML 文档&#xff0c;不管…

作者头像 李华
网站建设 2026/9/15 12:30:30

甘肃网站建设开发app避坑指南:3大方案实测告诉你别交智商税

甘肃网站建设开发app避坑指南:3大方案实测告诉你别交智商税 别再说“我的网站不够用”了,真相是:你买的那个998元的模板,从第一天起就注定要返工。 很多甘肃的朋友问我,为什么花了几千块做的网站,客户看了摇头,自己看着也难受?因为模板网站太丑且功能僵化,根本撑不起现在复杂的业务需求。…

作者头像 李华