FlexPrice 落地 Entitlement Grants 仪表盘:从配额模型统一到窗口化计量与订阅覆盖(FLE-1282)
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 的计量权益存在两条并行轨道:
- Grants 仅存在于 API 层:控制台既不能配置 Grants,也不能展示其状态。
- 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 出发继续追踪。