Herald

Herald 计费架构

Herald 的计费系统不维护本地商品目录。Stripe、Creem 这些支付方管理 Product、Price、Checkout 和订阅生命周期;Herald 只保留做授权和积分发放所需的最小数据。

Herald 的计费系统不维护本地商品目录。Stripe、Creem 这些支付方管理 Product、Price、Checkout 和订阅生命周期;Herald 只保留做授权和积分发放所需的最小数据。

给谁看

需要理解 Herald 计费模块设计的开发者。不涉及具体支付方的对接操作,对接指南见各支付方的单独文档。

核心概念

积分账户

积分账户是积分的隔离单元。一个 Realm 可以建多个积分账户,各个账户的余额互相独立、互不影响。

它解决的问题是:一个 Realm 下可能同时跑多条业务线,比如一个 AI 对话应用和一个图片生成应用,它们的积分要分开结算、分开消费,不能串。每个积分账户覆盖一组 Client App,只有被覆盖的 Client App 才能消费这个池的积分。

每个积分账户的字段:

字段说明
bucket_key标识,匹配 ^[a-z0-9-]{1,64}$,Realm 内唯一
name展示名
display_order展示顺序
enabled是否启用。禁用后对新用户不可见、不可购;已持有该账户的用户仍能消费剩余积分
receives_registration_credits是否是本 Realm 的注册积分接收账户

注册积分接收池:每个 Realm 至多一个积分账户标为 receives_registration_credits,数据库用部分唯一索引 uq_credit_buckets_registration_pool 保证唯一。用户注册时发放的积分、免费的周期性积分,都进这个账户。如果 Realm 没配接收池,这些系统发放的积分无处可去,直接不发放——不会偷偷落到某个隐式默认账户。Herald 没有"默认积分账户"这个概念,每一个积分账户都必须是管理员显式建出来的。

积分账户目录的管理 API 在 /api/realms/{realmId}/billing/credit-buckets 下,包含列表、详情、创建、更新、删除,外加一个 overview 接口返回每个积分账户 × 每种积分类型的余额矩阵和跨账户合计。全部需要 Realm Admin 的 points.manage 权限。

几个会直接报错而不是静默处理的约束:

  • 覆盖集不能为空。创建和更新都校验,空集返回 400。
  • 从一个积分账户移除已挂载的映射会被拒绝,返回 bucket_orphan_mapping。要移动映射,先把它挂到目标积分账户。
  • 删除积分账户时,如果还有活跃订阅、或有余额的钱包,返回 409 bucket_in_use
  • 改覆盖集只影响未来的路由,不回收已经发出去的余额。

池里的积分按来源分五种类型,余额页和后台概览都按这五类分别统计。积分账户概览矩阵和用户余额都沿这五个 key 展开:

key含义
topup充值积分(用户主动购买)
subscription会员积分(订阅套餐赠送)
registration注册积分(注册时一次性赠送)
free_periodic免费周期积分(按周期自动发放)
granted主动发放积分(管理员或 SDK 发放)

Entitlement Mapping

Entitlement Mapping 是一张映射表,把支付方的外部商品 ID 映射到 Herald 内部的 entitlement_key,同时归属到一个积分账户。它不是商品目录,是 allowlist 加同步缓存。

每条映射包含:

字段说明
payment_provider支付方名称(stripe、creem)
external_product_id支付方侧的商品 ID(如 prod_xxxx
external_price_id支付方侧的价格 ID(Stripe 有,Creem 不适用)
entitlement_keyHerald 内部的权益标识(如 pro-plan
bucket_id归属的积分账户。购买该商品后积分进入这个积分账户的池
billing_type计费类型:Recurring 或 OneTime
points_per_period每个周期发放的积分数
grant_on_subscribe首次订阅是否发放积分
validity_days积分有效期(天)
enabled是否启用。禁用后 webhook 仍更新订阅投影,但不触发积分发放

映射里还带一份从支付方同步过来的展示信息(provider_product_info),只读,用来在管理后台展示商品原貌,不参与计费:

字段说明
产品名列表和分组的主标签优先用它。缺失时回退到外部产品 id,再不行给占位,不显示空标签
价格展示时按支付方区分单位。Stripe 同步的是最小货币单位整数(例如 999 表示 9.99),换算成主货币单位展示;Creem 直接用原值(字符串如 "9.99"),不再统一除以 100。单位换算由支付方来源驱动,不跨支付方共用同一条换算分支
币种商品币种,跟随同步覆盖
计费类型recurring / one_time,来自支付方
计费周期Stripe 取 Price.recurring.interval(day/week/month/year),只读,不接受人工输入,重新同步以 Stripe 当前值为准。Creem 取其产品响应里的 billing_period(形如 every-month,前端做文案映射),缺失时显示"—",不伪造
metadata同步 Stripe Product.metadataPrice.metadata(商户自定义键值对),只读展示。Creem Product 无原生 metadata,按空处理,不伪造

展示信息每次重新同步以支付方当前值为准覆盖本地;entitlement_keypointsgrant 策略、quota_windows 等业务字段不被同步覆盖。

计费周期(来自 Stripe)和额度滚动窗口(quota_windows,如 5 小时 / 周)是两个独立概念,刻意解耦。计费周期决定 Stripe 何时扣款、何时发续费 webhook;额度窗口决定用户在滚动窗口内可用多少额度,两者不要求相等、不要求整除、不做对齐。长计费周期(年付)里仍可配滚动限额(月窗 / 周窗)。额度授予由续费 webhook 事件驱动,按 provider 返回的 (period_start, period_end) 锚定一期权益,不预发整周期总额、不按日历固定日清零。

映射通过两种方式产生:

  1. 管理员手动同步:调用支付方 Product API,拉取所有商品,自动创建或更新映射。新建的映射先绑定到本 Realm 的注册接收池,之后管理员可以把它挪到别的积分账户。
  2. Webhook 增量更新:支付事件触发时从 webhook payload 提取信息更新缓存。

同步失败时本地缓存继续服务,不会静默降级。

Subscription Projection

Subscription 是支付方订阅状态的本地只读副本,不是 Herald 拥有的订阅。SDK 和授权查询读取这个投影,不依赖实时支付方 API。

投影字段包括 realm_iduser_identitlement_keybucket_idstatuscurrent_period_start/endprovider_metadata 等。订阅在建立时就绑定到购买时那个 Entitlement Mapping 所属的积分账户,之后续费、升降级、取消、退款的积分发放和回收都回到同一个池,不会串到别的积分账户。需要计费周期或档位信息时,从 entitlement_keyprovider_metadata 派生。

订阅状态:Active、Trialing、PastDue、Canceled、Expired、Paused、Disputed、ScheduledCancel、Incomplete。

has_access() 方法判断用户是否有权限:Active 和 Trialing 状态返回 true,其他返回 false。

支付尝试(Payment Attempt)

支付尝试是记录每一次成功支付的统一账本,覆盖一次性购买、订阅首期和订阅续费。订阅续费以前只更新订阅周期和积分,不留下"这次支付"的记录;现在每次成功续费也写一条已成功的支付尝试,并作为外部发票的本地归属锚点。每张第三方同步的发票都能直接定位到它归属的支付尝试或订阅。零金额周期(100% 折扣 / 免费档)不产生续费支付尝试。归属和异常发现的细节见发票管理

Metadata 契约

所有 Herald 使用的 metadata key 统一用 herald_ 前缀。这些 metadata 写入 Checkout Session 或 Subscription,支付方在 webhook 中原样返回。

必填 metadata:

Key说明
herald_realm_idRealm ID
herald_client_app_idClient App ID
herald_user_id用户 ID
herald_entitlement_key权益标识
herald_billing_kindsubscriptionpoints_package

Checkout 创建时验证这些 metadata,缺失则拒绝创建。Webhook 收到事件后按以下顺序解析 entitlement_key:

  1. Webhook metadata 中的 herald_entitlement_key
  2. 本地映射表(按 provider + external_product_id 查询)
  3. 都找不到则记录错误,不静默跳过

积分策略

积分策略的 source of truth 是 Herald 本地的 Entitlement Mapping,不是支付方的 metadata。Stripe 的 Product.metadataPrice.metadata 会跟随产品同步进入展示信息(仅 Stripe 适用),Herald 端只读,不回写、不编辑。Creem 无原生 metadata 功能,必须在 Herald 中手动配置。

策略按 entitlement_key 查询,覆盖:首次订阅发放、续费发放、取消回收、退款回收、升降级处理。发放和回收都定位到订阅绑定的积分账户池,按上面五种 credit_type 分别入账。

管理员可以在 Herald 中修改积分策略。修改的是 Herald 的业务规则,不会回写到支付方。

数据流向

支付方 Herald 支付方 herald_* metadata 归属 绑定 发放/回收到指定池 Checkout Session Webhook Entitlement Mapping Subscription Projection 积分策略 积分账户池 同步商品 建积分账户 / 配覆盖集 配积分策略

用户支付流程:

  1. 前端调用 Checkout API,传入 entitlement_keypayment_provider
  2. Herald 查 Entitlement Mapping,找到对应的外部商品和它归属的积分账户
  3. Herald 调用支付方 API 创建 Checkout Session,metadata 写入 herald_* 字段
  4. 用户在支付方页面完成付款
  5. 支付方发 Webhook 给 Herald
  6. Herald 从 metadata 解析 entitlement_key,创建/更新 Subscription Projection,绑定到映射的积分账户
  7. Herald 按 entitlement_key 查积分策略,把积分发到该积分账户的池,或从该池回收

SDK 消费怎么路由

第三方应用通过 SDK 消费积分时只传 Client App 和金额,不感知积分账户。Herald 找到所有覆盖这个 Client App 的积分账户池,按过期时间从近到远跨池扣减,原子完成、不超额。如果没有任何覆盖的池、或覆盖的池合计余额不足,消费被拒绝并返回明确的余额不足提示。积分账户没覆盖这个 Client App 时,它的池不会被扣减。

支持的支付方

支付方商业目录订阅一次性购买(积分包)Metadata 支持
StripeProduct / Price APICheckout Session + SubscriptionPayment IntentProduct/Price/Checkout/Subscription 均支持
CreemProduct APICheckoutCheckoutCheckout metadata,webhook 返回

Stripe、Creem 是发起式平台,通过 PaymentAttempt 统一管理支付过程。

相关文档

On this page