解析:以 `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 回退到英文测试要求不缺失任何 keyschema.ts校验器在解析期就拒绝不自洽的编辑index.ts解析逻辑——本地化套餐、限额查询、Stripe price-id 解析pricing.test.ts护栏每次编辑后都要运行其中index.ts在模块加载时对pricing.json执行pricingCatalogSchema.parse(rawCatalog)校验失败直接throwindex.ts。这是因为目录是已提交的受信文件而非用户输入一次格式错误的编辑必须在 CI 和本地构建期暴露而不是让价格在 checkout 调用里悄悄变成undefinedschema.ts 的头部注释明确阐述了这一取舍。修改价格从 JSON 到 Stripe 的完整链路README 给出的改价三步是修改pricing.json中price.monthly单位是USD 美分——3900即 $39在 Stripe 创建对应价格并把套餐在stripePriceEnv.monthly中命名的环境变量设置到 SaaS 上如STRIPE_PRICE_PRO_MONTHLYprice_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_CONFIGUREDbilling.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且该边界由workloadsservices两个字段强制执行。这是一条关于诚实的规则而非品味问题。README 明确记录了历史教训过去用功能列表做差异化但那些功能从未被真正执行——Pro 卖过“内置邮件服务器”而mail.controller.ts在CLOUD_MODE下对所有邮件路由返回 404Starter 卖过“每次推送的预览部署”该功能根本没实现Scale 卖过“延长保留期的审计日志”而每个档位本来就有无门禁的审计日志。现在任何档位都成立的功能只出现在standard.features一次且只有真正在云上交付的才会列进去。两条测试守住了这条线differentiates tiers on usage and size, not on capabilitypricing.test.ts与retires the copy for capabilities cloud does not sellpricing.test.ts——后者显式点名mailServer与previewDeploys两个 key 必须保持退役状态。计算分钟Compute minutes的定义1 个计算分钟 low机器运行 1 分钟。更大的机器按倍数消耗倍数由computeUnitsPerMinute()从RESOURCE_TIER_SPECS派生index.tsmedium2×、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 tiers whole app cap around the clockpricing.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) × 1000index.ts其中buildMultiplier是构建机 vCPU 与low机 vCPU 之比。构建分钟进入这个求和是承重设计而非整洁性考虑toOblienCredits()拒绝非正配额而 free 档合法地发布 0 计算分钟——若只按计算量派生每次 free 档的配额推送都会 throw。测试grants every finite tier a POSITIVE quota, free includedpricing.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 enforcepricing.test.ts将限额 key 集合与实际能拒绝请求的东西逐一比对。限额到执行点的映射如下限额执行者workloadsplan-guardassertPlanAllowsDeployShapeservicesplan-guardassertPlanAllowsServicesrunningServicesOblienmax_workspaces plan-guardmaxProjectsplan-guardassertProjectQuotamaxResourceTierOblienmax_vcpus/max_ram_mb/max_disk_gbcomputeMinutesPerMonthOblien 信用点配额经planMonthlyCredits()buildMinutesPerMonthplan-guardassertBuildMinutesAvailablefreeSubdomainsplan-guardassertFreeSubdomainQuotacustomDomains/seats处处为 null 无限无需执行plan-guard.ts是整个云上“限额变成拒绝”的唯一地点每个限额都从planLimits(tier)读取、绝不硬编码因此客户在定价页看到的数字就是拒绝他的那个数字。拒绝以402 PLAN_UPGRADE_REQUIRED抛出并携带机器可读的reasonstatic-only、build-minutes-exhausted、free-subdomain-limit、resource-tier、running-services、project-limit让客户端能挑选正确的升级 CTAplan-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_workspacesrunningServicesoblien.buildWorkspaceHeadroommax_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 workspacepricing.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 Campaigncampaigns[]存放限时自动折扣——无需写代码自动对所有人生效。一个完整条目{ 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-30UTC 与本地时间有歧义且会提前一天结束——isoInstant正则要求完整 ISO-8601 时刻带 offsetschema.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、revokepromo-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-latnindex.ts。fill()对未知占位符原样保留因此翻译里的拼写错误会在评审时以{buildMinutes}字面量形式暴露而不是悄悄变空白。新增语言丢入locales/code.json把代码加进index.ts的PRICING_LOCALESindex.ts并保持与仪表盘的 locale 列表同步——测试会在这两者分叉时失败因为“翻译过的仪表盘配英文价格”比两者任一都更糟。当前九种语言为[en,ar,de,es,fr,ja,pt,tr,zh]测试covers exactly the dashboards 9 localespricing.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),仅供参考