FlexPrice 落地 Entitlement Grants 仪表盘:从配额模型统一到窗口化计量与订阅覆盖(FLE-1282)

原创2026-10-09 16:18:551,136 阅读

FlexPrice 落地 Entitlement Grants 仪表盘:从配额模型统一到窗口化计量与订阅覆盖(FLE-1282)

导读

本文基于 FlexPrice 开源仓库的设计文档 docs/design/2026-09-08-FLE-1282-entitlement-grants-ui.md,完整解读该方案如何把原本"仅 API 可用"的 Entitlement Grants(权益配额授予)搬进控制台(Dashboard),并以 Grants 为唯一载体统一平台的计量(metered)配额模型。你将掌握:Grant 与 Entitlement 的数据模型边界、unlimited 标志如何让计费零改动、六个写路径验证规则的落地、grant_state 读取 API 的 JSON 形状与查询索引设计、订阅覆盖(override)的继承语义,以及"方案虽已实现、仍待评审"的七个开放问题(open points)——它们恰恰是理解该架构深水区的最佳入口。


1. 目标:两个配额模型收敛为一个

在 FLE-1282 之前,FlexPrice 的计量权益存在两条并行轨道:

  1. Grants 仅存在于 API 层:控制台既不能配置 Grants,也不能展示其状态。
  2. Legacy 配额三元组:usage_limit / usage_reset_period / is_soft_limit 仍被用于计量特征(metered feature)。

本方案的落点有二:

  • 让 Grants 在控制台可配置、可见;
  • 把计量型权益全部迁移到 Grants 之上,让平台只保留一个配额模型。

第二点反过来规定了第一点的形态:创建表单没有 legacy 分支。一次性的(non-recurring)配额被表达为 grant_duration_unit = subscription_period 的 Grant;对一个计量特征,usage_limit / usage_reset_period / is_soft_limit 三元组永远不会再被发送。

"Grants only" 的精确含义是:每一个携带有限配额的计量权益都必须走 Grants。而 adjustMeterUsageEntitlement(legacy 计费调整路径)并不会被删除——它有五个不可替代的存活理由,详见本文第 5 节。

2. 背景:Grant 是 Entitlement 的物化,不是替代

一个关键概念澄清:Grant 不替代 Entitlement。

  • entitlements 表行始终是配置(规则);
  • 一条 Grant 是这份配置物化为一个具体时间窗口 + 一份用量快照。

两条模型在代码里只有一个交汇点——CalculateMeterUsageCharges 内部的开关(见 internal/ee/service/billing_meter_usage.go):

billing_meter_usage.go
  ├─ grants exist for this meter  → adjustMeterUsageGrants      (grants win)
  ├─ entitlement enabled          → adjustMeterUsageEntitlement (legacy)
  └─ neither                      → raw pricing

源码中这段优先级逻辑有明确注释佐证(internal/ee/service/billing_meter_usage.go#L150):当行项目的 meter 存在 Grants 时,adjustMeterUsageGrants 优先于 adjustMeterUsageEntitlement——Grants 取代该特征的 legacy 配额语义。

3. 交付清单:后端 6 项 + 前端 4 项

3.1 后端

# 变更 动机(Why)
1 两种 measure 的 tiered 价格都被拒绝 原先只在写入时拒绝 amount。数量型(quantity)Grant 能干净保存,但出账时 grantPricingGuard 拒绝,控制权落回 legacy 路径,而 nil usage_limit 会被读作 无限 → 按 $0 出账
2 订阅覆盖继承 Grant 配置 覆盖(override)在解析集中取代其父级。此前复制除 Grant 字段外的一切,会把该客户的 feature 静默降级回 legacy——这是既有 bug
3 无限配额(Unlimited) grant_quota 未设置 + subscription_period。此前这是唯一一个没有 Grant 形态表达的计量场景
4 用量汇总认识 Grants GET /customers/:id/usage 曾把每个配额都报成无限、用量为零
5 entitlement 读取带 grant_state 实时配额——窗口、用量、剩余、周期合计——此前完全没有读取面
6 AggregatedEntitlement 上的 Grant 摘要 窗口未开启前,读取 API 无法表达"每小时 1,000",于是所有界面都渲染成 "Unlimited"

3.2 前端

  • 创建表单:一个模式开关(recurring / once per billing period / unlimited)取代四个平铺控件,并附带"该配置在一个计费周期内会产出什么"的预览;
  • Plan、Addon、Subscription 页面新增 Value(值)+ Usage Reset(用量重置)列,对 Grant 支撑行与 legacy 行读取逻辑完全一致;
  • 订阅页新增实时配额仪表(live allowance meter);客户用量表支持按窗口展开的流水账(per-window ledger);
  • 创建订阅时的覆盖(override)编辑的是配额(allowance),而非 usage limit。

4. 低层设计

4.1 数据模型:唯一一次 Schema 变更

整个方案只有一处 Schema 变更:entitlement_grants.unlimited boolean NOT NULL DEFAULT false。

entitlements                          (config — 形状不变)
  grant_measure, grant_quota*, grant_duration_value*, grant_duration_unit,
  grant_allocation_behavior, aggregation_mode
      * grant_quota NULL + subscription_period  ⇒  unlimited

entitlement_grants                    (runtime — 每个窗口一行)
  quota, usage, valid_from, valid_to, grant_status, last_computed_at,
  quota_crossed_at, unlimited ← 新增

两个 Schema 文件可以直接对照阅读:

  • ent/schema/entitlement.go 中的 grant_* 字段族:grant_quota 为 numeric(25,15) 可空 decimal,grant_duration_value 可空 int,grant_allocation_behavior 默认 first_usage(注释说明:它决定打开 Grant 时如何锚定 validFrom,对 hour/day/week 单位有意义),aggregation_mode 默认 additive;
  • ent/schema/entitlement_grant.go:quota 列为 numeric(25,15) 且 Immutable()(不可变),unlimited 默认 false,usage 默认 0;quota_crossed_at 注释明确——它存的是评估时刻而非事件级精确穿越点,计费只对这一点之后的用量收费(对客户有利,且受防抖约束)。

为什么用 flag 而非可空 quota? 因为 quota 列是 NOT NULL 且不可变,所以"无限"只能是一个布尔标志。所有对 Quota 的读取此前已经收敛到三个领域方法(internal/domain/entitlementgrant/model.go),于是标志被藏进这三个方法内部:

func (g *EntitlementGrant) IsExhausted(usage decimal.Decimal) bool {
	if g == nil || g.Unlimited {
		return false
	}
	if !g.Quota.IsPositive() {
		return true
	}
	return usage.GreaterThanOrEqual(g.Quota)
}

func (g *EntitlementGrant) Overage() decimal.Decimal {
	if g == nil || g.Unlimited {
		return decimal.Zero
	}
	over := g.Usage.Sub(g.Quota)
	if over.IsNegative() {
		return decimal.Zero
	}
	return over
}

func (g *EntitlementGrant) Remaining() (decimal.Decimal, bool) {
	if g == nil || g.Unlimited {
		return decimal.Zero, false
	}
	remaining := g.Quota.Sub(g.Usage)
	if remaining.IsNegative() {
		// Exhausted, not unbounded: the ceiling exists, it has been passed.
		return decimal.Zero, true
	}
	return remaining, true
}

(文档示例为简化签名;仓库实际实现见 internal/domain/entitlementgrant/model.go#L62-L94,其中 Remaining 返回 (值, 是否有限) 二元组——无限时返回 false。)

推论:计费对无限配额零改动。 Overage() 返回零 → 快照(snapshot)路径求和为空 → 合并(merged)路径看不到任何穿越窗口。这在测试中也有覆盖:TestIsExhausted 与 entitlement grant 测试断言"无限窗口没有可穿越的天花板"(见 internal/domain/entitlementgrant/model_test.go 与 internal/ee/service/entitlement_grant_test.go#L2109)。

4.2 校验:一个共享函数、两个调用方,写入规则不漂移

grantMeterEligibility(meter, measure)      entitlement_grant.go
  ├─ MAX aggregation            → reject
  ├─ bucketed meter             → reject
  ├─ bucketed price             → reject
  └─ tiered price               → reject   (两种 measure 都拒绝)

validateGrantConfig(entitlement)           domain/entitlement/model.go
  ├─ 仅限计量特征(metered features only)
  ├─ quota 为 nil ⇒ duration 必须是 subscription_period
  ├─ quota 已设置 ⇒ 必须为正数
  └─ duration ≥ 1 小时

validateGrantSiblingCoherence(entitlement) entitlement_grant.go
  ├─ 每个 feature 只有一个 aggregation mode
  ├─ 每个 feature 只有一个 measure
  ├─ additive ⇒ 只能有一个 duration
  └─ 不允许 mixing unlimited with bounded      ← 新增

领域模型上还有一层兜底:EntitlementGrant.Validate()(internal/domain/entitlementgrant/model.go#L96)要求 quota 非负、usage 非负、valid_to 严格晚于 valid_from,并注释说明"窗口长度下限不在此校验——1 小时下限是配置层规则,周期边界处的窗口可以更短,覆盖(coverage)比窗口长度美学更重要"。

4.3 读取 API:grant_state 挂在聚合权益上

grant_state 挂在聚合后的 entitlement 上——配置与运行时一起返回:

"entitlement": {
  "grant_quota": "1000",              // 承诺值,任何窗口开启前即可读
  "grant_duration_unit": "hour",
  "grant_unlimited": false,
  "grant_state": {                     // 运行时
    "windows": [                       // 与周期相交的所有窗口,旧→新
      { "grant_id": "eg_...", "quota": "1000", "usage": "1600", "remaining": "0",
        "valid_from": "...", "valid_to": "...", "status": "exhausted",
        "is_active": false, "unlimited": false, "last_computed_at": "..." }
    ],
    "cycle_totals": { "windows": 2, "total_quota": "2000",
                      "total_usage": "2000", "total_overage": "600" }
  }
}

要点:

  • 一个数组,不是两个。 实时余额就是 is_active 为 true 的那一条(parallel 模式则可能多条)。它按服务器时钟求值,客户端永远不需要比较时间戳。
  • status 是配额状态(是否耗尽);is_active 是时间状态(窗口是否开启)。一个窗口可以是 status: active, is_active: false——它关闭了但从未被消费。
  • windows 为空意味着尚无窗口开启。这与"用量为零"是不同的事实,界面必须区分渲染(— / 1,000 · starts on first use)。
  • 出现在三个端点:GET /subscriptions/:id/entitlements、GET /customers/:id/entitlements、GET /customers/:id/usage。

DTO 层的真实结构(internal/api/dto/customer_entitlement.go)比文档示例更细:GrantState 实际持有 allowances []*GrantAllowanceState,每条包含 grant_id、entitlement_id、measure、unlimited、quota、usage、可空 remaining、valid_from/to、status、is_active、last_computed_at、quota_crossed_at;AggregatedEntitlement 内嵌 GrantConfig(grant_measure、grant_quota、grant_duration_value、grant_duration_unit、grant_unlimited)并带 grant_state 字段。

查询形态(GrantStateByFeature):

filter.WithCustomerIDs(sub.CustomerID).      // 查找索引的首列
      WithSubscriptionIDs(sub.ID).
      WithScopeEntityType(feature)
filter.WithCycleOverlap(cycleStart, cycleEnd)

customer_id 在逻辑上与 subscription_id 冗余,但它是 (tenant, env, customer_id, valid_to, ...) 索引的前导选择性列——没有它,查询规划器会扫描整个租户的 Grants。这与 ent/schema/entitlement_grant.go 中第二组索引 (tenant_id, environment_id, customer_id, valid_to, entitlement_config_id, subscription_id) 一一对应:等值 + valid_to 范围扫描把每次查询限制在当前周期。

4.4 覆盖(Override)语义:缺省即继承

OverrideEntitlementRequest 携带六个 Grant 字段。省略 = 继承——只改配额(quota)的覆盖会保留 plan 的节奏(cadence)和 measure。没有 "clear" 模式,因为 clear 唯一可达的结果是 unlimited(已可表达)或 退回 legacy(Grants-only 要消灭的正是它)。

合并后的行在插入前要经过校验——一个覆盖可能把合法的父级推进不合法的组合。

5. 无论切到何种程度都保留的 legacy 路径

adjustMeterUsageEntitlement 不能删除,五个场景让它必须存活(对应代码位置 internal/ee/service/billing_meter_usage.go#L460):

场景 原因
Boolean / static / config 特征 Grant 配置仅限计量特征
非 subscription_period 节奏上的无限配额 被拒绝;无限配额要求周期窗口
非分桶(non-bucketed)MAX meter 峰值无法在时间窗口上分解
分桶 SUM + 固定费价格 Grants 拒绝任何分桶价格;legacy 通过显式豁免允许它
tiered 价格 meter 对 Grants 而言超出范围(§3.1 #1)

6. 关键代码索引(Reference)

关注点 位置
Grant/legacy 分支 internal/ee/service/billing_meter_usage.go;legacy 调整在 :421 附近(实现于 :460)
Grant 超量(overage)折叠 billing_meter_usage_grants.go:82;guard 在 :263
窗口数学 entitlement_grant.go:623;subscription_period 分支在内
追补循环(catch-up loop) entitlement_grant.go:528
共享 meter 规则 entitlement_grant.go 的 grantMeterEligibility;siblings 在 validateGrantSiblingCoherence
字段一致性 domain/entitlement/model.go 的 validateGrantConfig
读取状态 entitlement_grant.go 的 GrantStateByFeature(internal/ee/service/entitlement_grant.go#L1088);合并于 billing.go 的 attachGrantState(internal/ee/service/billing.go#L2996)
聚合 billing.go:2365(metered)、:2500(grouping)
用量汇总 billing.go:3189
覆盖路径 subscription.go:7079

相关既有设计:2026-07-08-FLE-959-Entitlements-Revamp.md(§2 概念、§7 限制)、2026-08-27-entitlement-grant-proration-erd.md(§6 已知缺口),均在 docs/design/ 目录下。

7. 开放问题:实现完成、评审待续

以下问题被刻意搁置,不是遗漏。它们都在测试切量(cutover)过程中浮出水面,按"可能让客户感到意外"的程度排序,且均不阻塞切量。

7.1 覆盖目标由前端选择

覆盖被建模为两条 entitlements 行之间的边:subscription 作用域的行携带 parent_entitlement_id,takeOverGrantWindowsFromParent 计算 delta = override − quota_of(parent)。后端信任这个指针,不重新推导。

指针在 React 侧生成(subscriptionEntitlementHelpers.ts 的 parentEntitlementIdForOverride),其中间回退是 getParentSource:取 sources[] 中第一个 entity_type 为 plan 或 addon 的元素。该数组由 billing.go 的 AggregateEntitlements 按 resolver 迭代顺序追加构建,没有任何约束。

于是:某个 feature 同时由 plan entitlement(1,000)和 addon entitlement(100)供量时,把配额改成 500 会得到 delta = −500 或 delta = +400——取决于行序——周期配额变成 600 或 1,500。这是未定义行为(undefined behaviour),不是一条碰巧出错的规则。

两件事纠缠在一起,都值得修:

  • 分层(Layering):客户端只知道"用户在订阅 S 上把 feature F 输入为 500"。替换哪些 entitlement、delta 是多少,是计费逻辑,不该在浏览器里执行。端点应接受 {feature_id, grant_quota},其余由服务端解析。
  • 模型(Model):覆盖本质是一条作用域声明("本订阅管辖 feature F"),而不是行到行的边。这样表达就没有"目标可选",歧义消失而非被糟糕地解决。

7.2 覆盖替换的是"一条 entitlement",不是"该 feature 的配额"

承接 7.1:filterOverriddenEntitlements 只压制 parent_entitlement_id 点名的那一行。同一 feature 的其他贡献者仍然存活并继续汇入池子。

对 plan entitlement 输入 2,000,而订阅同时带有该 feature 的 100 单位 addon entitlement,产出的窗口是 2,100 单位。表单每个 feature 只提供一个字段,用户输入的数字 ≠ 他们得到的数字。两条写入路径行为一致——这不是周期中(mid-cycle)的偶然现象。

修复方向:压制键改为 (subscription_id, feature_id),delta = override − Σ(所有被压制 entitlement)。随之而来两个约束:

  • 每个 (subscription, feature) 只有一个覆盖。当前创建屏按 entitlement 逐行展示,两条 override_entitlements 条目可能点名同一 feature 的两个贡献者。在 feature 级语义下这是"一个槽位两个压制器"——换了个地方重演排序问题。创建 UI 应收敛为每个 additive feature 一行,校验兜底。
  • 仅限 additive。Parallel entitlement 产生各自独立、独立计费的窗口,没有单一数字可覆盖;那里保留逐 entitlement 编辑。aggregation_mode 在分支点已可用。

可接受的代价:后续为已带覆盖的 feature 附加 addon,客户为 addon 付费但配额不变。这是"该客户恰好得到 N"的正确读法,但需要在 addon 附加时给出警告,否则看起来像 bug。

7.3 两条写入路径对"可覆盖对象"意见不一

ProcessSubscriptionEntitlementOverrides(创建订阅)直接拒绝 addon entitlement——"只有 plan entitlement 可被覆盖"。而周期中路径(POST /v1/entitlements,entity_type=SUBSCRIPTION + parent_entitlement_id)不加此限制,乐意覆盖 addon entitlement。

同一产品动作、两套规则。无论 7.2 如何收场,二者应一致。

7.4 没有周期中的"实际在跑什么"视图

创建之后,编辑面每个 feature 只暴露一个字段,没有指示配额来源。用户看不到 2,100 是 2,000 override + 100 addon,也无法删除覆盖以回到 plan。

提案:每个 grant 窗口一行可编辑——每个 additive feature 一个数字,下方带只读贡献者明细;parallel feature 每个 entitlement 一行。删除覆盖映射到 DELETE /v1/entitlements/:id(删子行)+ settleGrantWindowsForDeletedEC(已接好)。删除 addon 的贡献应保持为 addon 解绑(detach),而不是 entitlement 删除——订阅不应声称出售一个配额已被清零的 addon。

这应与 7.2 一起上线,而非在此之前。单独上线只会让当前的逐 entitlement 行为可见而非正确。

7.5 两条计费通道对"超量从何时开始"意见不一

adjustMeterUsageGrants 按折叠中 distinct entitlement config id 的数量分支:

  • 一个 id——快照通道(snapshot lane):每窗口 Σ max(0, usage − quota),完全忽略 quota_crossed_at;
  • 多个——区间通道(interval lane):只对落在合并后 [quota_crossed_at, valid_to) 区间内的用量计费。

因此 pooled(additive)feature 从越过配额后的第一个单位开始计费,而 parallel feature 原谅跨越被检测到之前消费的一切。两条通道存在理由不同,各自未必错,但同一个客户可能同时体验两者。这需要在统一代码前做出产品决策。

7.6 entitlement_config_id 在 grant 上承载两种含义

对 additive feature,该列保存的是槽键(slot key)——由 grantCandidatesForFeature 纯粹为了给唯一索引 (tenant, env, entitlement_config_id, customer, subscription, valid_from) 一个稳定的仲裁点而选的最低 ULID 贡献 entitlement。它不是归属(attribution):窗口可以落在 addon 的 entitlement id 上,而其配额主要来自 plan;覆盖 plan 的 entitlement 会重切该窗口而 id 不变。

把它读成"产出这条 grant 的 entitlement"是自然但错误的推断,这让 7.1 的调查比必要困难得多。拆成 slot_key + 贡献者列表会让 grant 行自解释。

7.7 解析后的配额在四个地方重建

"该订阅为 feature F 授予什么、来自哪里"有四个独立实现:

位置 产出 分歧点
filterOverriddenEntitlements 幸存 entitlements 按 parent 指针压制(7.2)
AggregateEntitlements 每 feature 折叠 + sources[] 数组顺序无约束(7.1)
grantCandidatesForFeature 槽形状与求和配额 选最低 ULID entitlement(7.6)
enrichSubscriptionEntitlements(前端) 覆盖哪个 entitlement 三级回退(7.1)

7.1–7.6 的大部分问题就活在它们的缝隙里。一个统一的 ResolveFeatureAllowance(sub, featureID) → {Total, Contributors[], Slot},被所有读取路径调用并把明细返回给客户端(前端直接渲染而非推导),会消除分歧——这也是 7.2 和 7.4 共同需要的地基。

7.8 小项

  • grant_unlimited、swaggerignore 增补、以及现在可空的 remaining 均未重新生成 Swagger;
  • Plan 与 addon 的 entitlement 编辑在控制台保持禁用(edit={{ enabled: false }});只有 subscription 级编辑可达。

8. 阅读指南:按产品动作追踪调用链

两条规则让其余内容可读:

  • Entitlement 是规则;Grant 是这条规则物化为一个时间窗口 + 用量快照。写规则永不直接触碰窗口。
  • 用量是重新测量,从不累积。任何手工写入 entitlement_grants.usage 的值会在下一趟被覆盖。

8.1 在 plan 上创建 entitlement

Plan 页 → Add entitlement。

POST /v1/entitlements                                      router.go:473
→ EntitlementHandler.CreateEntitlement
→ entitlement.go:48   CreateEntitlement
    :226   deriveGrantConfig             usage_limit → grant_quota; unset → unlimited
    entitlement_grant.go:889  validateEntitlementGrantShape
      :917   grantMeterEligibility       MAX / bucketed / tiered → reject
      domain/entitlement/model.go  validateGrantConfig
      :1004  validateGrantSiblingCoherence   one mode and measure per feature
    EntitlementRepo.Create
    :189   takeOverGrantWindowsFromParent    no-ops — not subscription-scoped

不触碰任何 Grant。plan entitlement 在订阅解析它之前不影响任何东西。

8.2 创建带覆盖的订阅

New subscription → 保存前编辑任一列出的配额。

POST /v1/subscriptions        (override_entitlements[])
→ subscription.go:368
→ subscription.go:7143  ProcessSubscriptionEntitlementOverrides
     仅校验 plan entitlements — addon 覆盖在此被拒(§7.3)
     每条目:创建 subscription 作用域的 entitlement,
              parent_entitlement_id = 目标,
              省略的 grant 字段从父级继承(§4.4)

仍不产生 Grant。窗口在 8.5 才开启。

8.3 周期中编辑配额

两条流程。前端通过检查该 feature 是否已存在 subscription 作用域的 entitlement 来选择。

(a) 首次触达该 feature——创建覆盖

POST /v1/entitlements
  entity_type=SUBSCRIPTION, entity_id=subs_…, parent_entitlement_id=ent_…
→ entitlement.go:48   CreateEntitlement
→ entitlement.go:951  takeOverGrantWindowsFromParent
     delta = override − parent.quota                       source override_created

(b) 覆盖已存在——编辑它

PUT /v1/entitlements/:id
→ entitlement.go:719  UpdateEntitlement
     grantConfigMoved(before, after)     比较六个 grant 字段
→ entitlement.go:878  resettleGrantWindows
     delta = new − prior                                   source entitlement_updated

两者汇合于同一路径:

entitlement.go:901  reissueGrantWindows
  ListGrants                              feature 作用域的活跃窗口
  GetSubscriptionGrantECsByFeature        该 feature 的完整贡献者集合
→ entitlement_grant.go:163  ReissueEntitlementGrants        [DB.WithTx]
     :85    CloseEntitlementGrants        valid_to = now, usage frozen
     :223   OpenFeatureBasedEntitlementGrants
              后继配额 = closed.Remaining() + delta

恒等式 (old − usage) + (new − old) = new − usage 保证了安全性:已消费的用量既不会被退款,也不会被重复计费。

8.4 移除覆盖、删除 entitlement、解绑 addon

DELETE /v1/entitlements/:id
→ entitlement.go:1006  DeleteEntitlement
→ entitlement.go:970   settleGrantWindowsForDeletedEC
     override → delta = parent − override            source override_removed
     net-new  → handleGrantsForRemovedECs            source entitlement_gone
→ reissueGrantWindows → ReissueEntitlementGrants     (同 8.3)

Addon 解绑经由同一条路线到达 handleGrantsForRemovedECs。

8.5 一个 grant 窗口开启

没有任何 API 调用开启窗口。只有两个触发器:

(a) 定时任务    temporal/activities/alerts/alerts.go:71
             → alert_evaluation.go:177  EvaluateSpendAndEntitlementAlertsForCustomer
             → alert_evaluation.go:207  EnsureGrantsForSubscriptions

(b) 出账前      invoice.go:2231
             → alert_evaluation.go:222  RefreshEntitlementGrantsForCustomer
             → alert_evaluation.go:236  EnsureGrantsForSubscriptions

然后是:

entitlement_grant.go:372  EnsureGrantsForSubscriptions
  :455  buildGrantEvalMeta               meters, external customer ids
  :546  openMissingGrants
     :572  eligibleGrantConfigsByFeature    运行 resolver
     :605  grantCandidatesForFeature        additive → 一个槽,配额求和,
                                           落在最低 ULID entitlement 上(§7.6)
                                            parallel → 每个 entitlement 一个槽
     :647  openIfSlotFree                   唯一索引仲裁竞态
        :681  openOneGrant
           :743  computeGrantWindow         subscription_period 在此分支
           :845  earliestUncoveredUsage     first_usage 分配

三个闸门,默认全关,命名上都带 alerts 却实际控制配额是否物化:FLEXPRICE_TEMPORAL_ENABLED、usage_alerts.enabled、以及租户级 entitlement_alert_config.alert_enabled。

8.6 用量被测量

同一趟,紧随窗口开启:

alert_evaluation.go:250  evaluateEntitlementGrantsForCustomer
  :314 → :368  refreshEntitlementGrantUsage
     ClickHouse 查询覆盖 [valid_from, min(now, valid_to))
     绝对写入 usage;首次穿越时盖戳 quota_crossed_at
  :417  transitionEntitlementGrantAlert

8.7 出账

invoice.go:2231  RefreshEntitlementGrantsForCustomer     先把 grants 更新到最新
→ billing_meter_usage.go:53  CalculateMeterUsageCharges
     loadEntitlementGrantsByMeterID                      billing_meter_usage_grants.go:35
     ├─ 找到 grants → :82  adjustMeterUsageGrants
     │      :264  grantPricingGuard        被拒则控制落回 legacy
     │      :127  len(ecIDs) <= 1 ? snapshot : :169 mergedOverage      (§7.5)
     ├─ entitlement 启用 → billing_meter_usage.go:421  adjustMeterUsageEntitlement
     └─ 都没有         → 原始定价

8.8 每个界面调用什么

界面 客户端调用 后端
Plan / Addon entitlements EntitlementApi.search QueryEntitlements
Subscription → Entitlements 页签 SubscriptionApi GET /:id/entitlements GetAggregatedSubscriptionEntitlementsForSubscription → AggregateEntitlements + GrantStateByFeature
Customer → Entitlements CustomerApi GET /:id/entitlements GetCustomerEntitlementsForSubscriptions
Customer → Usage 表、GrantWindowLedger CustomerApi GET /:id/usage usage summary + grant_state.windows
添加 / 编辑 entitlement 表单 EntitlementApi.create / .update 8.1、8.3a、8.3b
带覆盖的订阅创建 SubscriptionApi create 8.2

任何写入触发前,subscriptionEntitlementHelpers.ts:76 计算请求携带的 parent_entitlement_id——见 §7.1。

8.9 推荐阅读顺序

8.3 和 8.5 是脊柱——周期中重切(mid-cycle re-cut)与物化(materialization)。8.7 只有在 8.6 清楚之后才有意义,因为计费读取的是刷新趟写入的内容。先读 8.1、8.2 建立词汇表,再读脊柱,最后是测量与计费。

结语

FLE-1282 是 FlexPrice 计量权益体系的一次收敛式重构:以"Grant = Entitlement 的窗口化物化"为心智模型,用 unlimited 标志让无限配额免费融入既有领域方法,让计费零改动;用一组共享校验函数防止写入规则漂移;用 grant_state 把"承诺值 + 运行时流水"一次性暴露给读取面。同时,设计文档诚实地记录了七项开放问题——覆盖目标的归属、替换粒度的语义、双写入路径分歧、双计费通道分歧等——它们共同勾勒出"配额即作用域声明"的下一步演进方向。对希望深入 FlexPrice 权益子系统的开发者而言,本文第 6 节与第 8 节共同构成一份按产品动作组织的调用链地图,可以直接从 internal/ee/service/entitlement_grant.go 与 internal/ee/service/billing_meter_usage.go 出发继续追踪。

登录后查看全文
flexprice