设计解析:Addon 附加与解绑的 ERD 与控制流)
【免费下载链接】flexpriceUsage-based pricing and billing for developers Cloud or self-hosted ⚙️ No-code UI Realtime usage metering Credits top-ups Control feature access项目地址https://gitcode.com/gh_mirrors/fl/flexprice点击查看免费下载导读本文深入解析 FlexPrice 开源仓库中《Entitlement Grant Proration — Addon Attach Detach — ERD》设计文档对应 PR #2614状态Implemented。该设计解决一个具体而关键的业务问题当客户在计费周期中途附加attach或解绑detach一个 addon 时其携带的功能权益配额entitlement quota应当按剩余周期比例授予而非一次性发放整个周期额度。读完本文你将掌握 FlexPrice 权益授予entitlement grant的底层数据模型entitlement_grants表、分段窗口segment window的平铺tiling机制、resolveGrantProration/materialiseEntitlementGrants/handleGrantsForRemovedECs三个核心方法的控制流以及 23 个已验证场景与 6 个刻意不做的产品决策并可直接定位到对应源码验证实现。1. 背景与动机为什么需要按比例授予在本次改动之前FlexPrice 的权益授予求值器grant evaluator只会在用量驱动usage-driven的 tick 上惰性开启窗口且请求change request不在求值作用域内。这意味着在一个 30 天计费周期的第 20 天附加一个 addon剩余 10 天却会拿到整周期的配额——客户为 1/3 的时间付了费却获得了全额额度。本 PR 的核心思路是让attach 与 detach 路径自己写入授权分段grant segment使按比例分摊在变更发生的当下就成立而不是等到下一条事件恰好到达时才被求值器补算。1.1 改动范围总览领域变更附加时授权按比例分摊新增resolveGrantProrationmaterialiseEntitlementGrants解绑时授权结算新增handleGrantsForRemovedECs窗口生命周期新增CloseWindow仓库方法分段平铺而非原地修改授权审计追踪新增entitlement_grants.metadata列权益读取Addon 关联改为按liveness活跃性解析而非周期重叠信用授权按比例分摊重构至共享系数助手shared coefficient helper之上1.2 明确不做的事产品决策非缺陷暂缓事项说明解绑时回收已授予配额clawback§6.1未来将成为客户可配置的 carry-forward 策略并行模式parallelfeature 的按比例分摊§6.4parallel 设计上就是 attach 即重置静态节奏hour/day/week的按比例分摊§6.4当前仅支持订阅周期subscription-period加法模式静态节奏的覆盖缺口coverage gap§6.5已知缺口2. 数据模型一张 ERD 与一个新增列2.1 ERD 全览2.2 Schema 层面的唯一变更Schema 层面只增加了一列entitlement_grants.metadata jsonb。这一点在 ent/schema/entitlement_grant.go 中可以验证——该表新增了field.Other(metadata, types.Metadata{})并以jsonb存储。其余全部是行为层面的改动。尤其是授权行grant rows是不可变事实immutable facts——周期中途的权益变更绝不会原地修改quota。它结束当前窗口并在旁边开启一个后继窗口successor二者精确平铺tile。attach at T cycle start | cycle end | | | ------------------------------------------------ | segment A | segment B | | quota 100 | quota A.remaining Δ | | usage 30 | usage 0 | ------------------------------------------------ A.valid_to B.valid_from其中Δ coefficient × addon_quota系数按秒计算coefficient (period_end − proration_date) / (period_end − period_start)OpenFeatureBasedEntitlementGrants拒绝valid_from不等于前驱valid_to的后继窗口——缺口或重叠是硬错误hard error而非静默漂移。对应源码见 internal/ee/service/entitlement_grant.go 中OpenFeatureBasedEntitlementGrants对req.Closed.ValidTo与validFrom的相等性校验不匹配即返回successor window does not tile with the closed grant的验证错误。2.3 写入 metadata 的审计字段KeyAttach 分段Detach 后继窗口proration_sourceaddon_attachaddon_detachproration_coefficient✓—proration_original_quota✓—proration_period_start/_end✓—proration_date✓—proration_strategysecond_based—carry_forward_from—前驱 grant id这套审计字段由 internal/domain/proration/coefficient_helper.go 中的AuditMetadata(p AuditParams)统一渲染。源码中值得注意的实现细节proration_coefficient的值本身就说明是否发生了按比例分摊——系数为 1 意味着没有缩放例如proration_behavior: none或变更恰好发生在周期起点。源码中定义的四类来源常量见 internal/ee/service/entitlement_grant_proration.goaddon_attach、addon_detach、addons_modify、entitlement_deleted。最后一个不是 addon 变更而是权益被整体删除的情况。审计时间戳统一以time.RFC3339格式的 UTC 时间写入。3. 控制流附加Attach3.1 Attach 时序图attach 流程的代码骨架对应 internal/ee/service/subscription_grants.go 中的persistAddonAttach其内部调用s.resolveGrantProration(ctx, sub, srcECs, survivingByFeature, src.EffectiveDate, src.Behavior, src.Origin)以及 internal/ee/service/entitlement_grant_proration.go 中resolveGrantProration与applyEntitlementGrantChange的实现。3.2 两个值得记住的微妙点其一at max(effectiveDate, now)。未来日期的 attachfuture-dated在变更真正生效的时刻切开窗口而不是当下。回溯日期的 attachbackdated不能重切已经被用量测量过的窗口因此钳制到now——已发生的历史用量不能因为回溯授权而重新归属。CloseEntitlementGrants中对应的边界计算见 internal/ee/service/entitlement_grant.goboundary : types.EarliestOf(types.LatestOf(closeAt, lastComputed), g.ValidTo)其二未求值过的窗口是删除而不是拆分。如果last_computed_at valid_from说明该窗口从未被测量过last_computed_at为 null 或早于窗口起点。此时拆分会让两个半边各自允许同样的配额——因为它真正消耗的用量从未被折入后继窗口的余额。删除它并让后继窗口横跨原窗口才能保证每个事件都只计入一个池子。源码逻辑CloseEntitlementGrantsif !lastComputed.After(g.ValidFrom) g.QuotaCrossedAt nil { // 删除该行让后继窗口横跨原窗口 s.EntitlementGrantRepo.Delete(ctx, g.ID) ... }注意还有一个额外豁免已经处于超额overage状态的窗口QuotaCrossedAt ! nil不会被删除——它生来就是跨过配额的承载了 tick 未写入的状态若替换它会把手上的超额用量回溯性地授予新配额。4. 控制流解绑Detach4.1 Detach 决策流程图detach 分支的落地实现是removalClosures见 internal/ee/service/entitlement_grant_proration.go其注释精确复述了上述决策树无 live 窗口→ 直接跳过例如未来日期的移除变更尚未生效。feature 上存在任何 parallel EC→ 只关闭entitlement_config_id属于被移除配置的行不开后继并行槽位各自独立addon 离开即槽位关闭。additive、池中Remaining 0已花光→保持窗口开启。关闭它会让 tick 依据幸存配置重新推导出全新配额把池子已消耗的额度又送回来。源码注释Leave the window to run out: granted quota is never taken back。additive、池中还有余额、但 feature 上已无幸存 EC→ 关闭且不开后继配额全额收回。additive、池中有余额、仍有幸存 EC→ 关闭并开启后继窗口后继quota 前驱剩余余额。后继窗口通过carryForward机制生成applyEntitlementGrantChange中其 metadata 写入proration_source与carry_forward_from指向被继承余额的前驱 grant id。关键点零增量场景下后继窗口的 quota 被显式设为 0但行仍必须存在——它承担持有槽位hold the slot的职责防止 tick 在幸存配置下再开一个并列窗口。OpenFeatureBasedEntitlementGrants中对应逻辑零配额的后继窗口以grant_status exhausted且quota_crossed_at valid_from开启即从诞生的第一刻起就处于已跨过配额状态。5. 权益读取Entitlement Reads的两处变更GetSubscriptionEntitlementsForSubscription有两处改动liveness 而非周期重叠现在请求addon_status IN (active, cancelled)并附加新的ActiveAt过滤器替代原先的active 周期重叠判断。为什么因为周期结束时取消和周期中途取消都会写入addon_status cancelled二者只能靠end_date区分而ActiveAt now才是区分仍享受你付费周期内的权益与从现在起被收回的判据。对应 DTO 为 internal/api/dto/addon.go 中GetActiveAddonAssociationRequest.ActiveAt字段。withAssociationWindow收窄窗口将每个 addon 权益收窄到该关联association自身的窗口。权益是目录级catalog row记录被该 addon 的所有关联共享——若不复制窗口第 20 天附加的 addon 看起来像是从周期起点就存在其授权窗口就会回溯覆盖早于它的历史用量。两处都是加法式变更AddonStatuses为空时默认[active]ActiveAt为 nil 时跳过该过滤因此GetActiveAddonAssociation的其他所有调用方行为与之前完全一致。6. 槽位所有权Slot Ownership谁有资格写分段一个 feature 具有槽位slots。槽位如何存在取决于aggregation_mode而这正是决定授权路径能否写分段的判据模式槽位配额谁拥有分段additive默认单个位于 lowest-id EC 上每个 ECgrant_quota之和attach/detach 直接写入当节奏为subscription_period时parallel每个 EC 一个各 EC 自己的grant_quota仅求值器 tick几个关键判定函数见 internal/ee/service/entitlement_grant.gohasParallelECs刻意用 any 而非 allfeature 上只要有一个 parallel EC整个 feature 就按 parallel 处理因为两种模型无法在同一个池子里自洽共享。实现上hasParallelECs就是lo.SomeBy判断是否存在defaultedMode(ec.AggregationMode) Parallel的 EC。混合节奏在创建时即被拒绝validateGrantSiblingCoherence会校验同一 feature 上所有 grant 配置的聚合模式、度量与节奏一致性。shouldOpenGrantManually是闸门只有当一个 feature 上的每一个EC 都是 additive且处于subscription_period节奏时attach 路径才手动写该 feature 的分段其余情况全部回落到 tick。实现见 internal/ee/service/entitlement_grant_proration.gofunc shouldOpenGrantManually(featureECs []*entitlement.Entitlement) bool { return lo.EveryBy(featureECs, func(ec *entitlement.Entitlement) bool { return ec ! nil defaultedMode(ec.AggregationMode) types.EntitlementAggregationModeAdditive ec.GrantDurationUnit types.EntitlementGrantDurationUnitSubscriptionPeriod }) }此外resolveGrantProration还有几道跳过前置条件源码可逐一核对无 grant 配置的 ECHasGrantConfig() false直接过滤无法解析出计费周期FindPeriodForDate失败→ 跳过并打日志变更落在下一个计费周期!p.Start.Before(sub.CurrentPeriodEnd)→ 跳过live 窗口不动存在无限额IsUnlimitedGrant()→ 跳过计算出的增量delta不为正 → 跳过。prorationDate的取值规则同样在源码中清晰可见默认取周期起点p.Start当behavior CreateProrations且生效日期晚于周期起点时取effectiveDate——这正好对应文档中的 attach 公式Δ coefficient × addon_quota。7. 已端到端验证的场景23 个以下所有场景均针对运行中的真实服务器端到端验证直接读取entitlement_grants表并与独立计算出的期望值比对。单元测试位于 internal/ee/service/entitlement_grant_proration_test.go其中可见assertTiled校验后继valid_from 前驱 valid_to、assertCutAtChange校验窗口在变更点被截断等断言助手以及mid-cycle attach 的系数必须小于 1coefficient.LessThan(1)与周期起点 attach 系数为 1s.Equal(1, ...proration_coefficient)的显式断言。#场景行为1周期中途 attachcreate_prorationsquota remaining coefficient × addon_quota精确到 15 位小数2周期中途 attachnone系数 1全额配额叠加3周期内未来日期 attach前驱在未来的日期被截断后继从该日期起按比例4落入下一周期的 attach整体跳过live 窗口不受影响5回溯日期 attach钳制到 line item 的起点授权与计费覆盖同一窗口6在订阅尚未拥有的 feature 上 attach窗口从 attach 时刻开启仅按比例配额不回溯覆盖早期用量7年付计费周期365 天系数 0.726027配额精确8两个 addon 依次 attach增量累积到一个窗口上9两个 addon 并发 attach一个连贯窗口无重叠无重复10attach 到未求值窗口前驱删除后继横跨原窗口11周期中途 detachadditive后继精确继承前驱剩余余额12周期中途 detachparallel仅关闭 addon 自己的槽位plan 的槽位原样保留13窗口已耗尽时 detach窗口保持开启不返还新配额14周期末默认detach授权窗口不动——付费周期内仍享权益15解绑 feature 上最后一个 EC配额全额收回无后继16Preview什么都不写不建行、不关窗口17旧版POST/DELETE /subscriptions/addon与 modify API 行为完全一致18无 grant 配置的权益不受影响不产生授权行19其他 modify 类型quantity_change等授权保持原样20周期末取消后的读取addon 权益保留21周期中途取消后的读取addon 权益立即收回22信用授权按比例分摊与重构前助手行为一致23单 feature 混合节奏创建权益时即被拒绝针对场景 17路由层可在 internal/api/v1/subscription.go 中确认POST /subscriptions/:id/addon与DELETE /subscriptions/:id/addon仍存在与POST /subscriptions/:id/modify/execute并行。场景 16 的 Preview 分支由 modify API 的 preview/execute 拆分internal/api/router.go 中的POST /:id/change/preview与POST /:id/change/execute承载——preview 只计算不落库。8. 刻意不支持的场景及原因产品决策以下每一项都是已拍板的立场而非疏漏。列出它们是为了让后来者不要顺手修掉。8.1 反复 attach/detach 会累积配额每次 attach 都会叠加一份按比例切片每次 detach 则把整个剩余余额含该切片向后继承。没有任何逻辑减去离开的 addon 的份额因此这对操作不会相互抵消。以一个每周期授予 100 单位的 plan 为例baseline 100.000 cycle 1 121.612 cycle 2 173.224 cycle 3 224.836 cycle 4 276.447决策当前是有意为之。已授予的配额永不回收clawback——这与 credit grant 遵循的策略一致。未来 carry-forward 将成为客户可配置的选择届时 clawback 分支会落在这里。8.2 周期中途 detach退了钱但保留配额detach 时的create_prorations会为未使用的时间发放钱包信用wallet credit而 addon 的配额贡献却存活到周期结束。决策在 detach 时使用proration_behavior: none。不发放退款保留配额也就自洽了。§8.1 与此是同一机制的一次性版本与反复版本。8.3 attach addon 会重置 parallel 与静态节奏 feature 上已消耗的用量materialiseEntitlementGrants会关闭变更涉及到的每一个feature 的每一个live 窗口——包括它并不拥有的槽位——这样 tick 可以立即交出进来的配额而不是等窗口自然到期。tick 随后以配置的全额配额、usage 0重新开启这些槽位。一个 80/100 已用的 plan EC 会回到 100 可用。决策这是设计意图。在 feature 上附加 addon 就是为了重置该 feature 的 parallel 限额。8.4proration_behavior对 parallel 或静态节奏无效同样的请求得到的可能是按比例配额也可能是全额配额取决于 feature 的聚合模式与节奏而且是静默的additive · subscription_period Δ 51.6129 (coefficient 0.516128) parallel · subscription_period Δ 100.0000 (not prorated) additive · day Δ 100.0000 (not prorated)决策按比例分摊目前仅限定于 subscription-period additive feature。值得重新审视API 是否应该对其它组合拒绝create_prorations而非忽略它。8.5 静态节奏在 attach 后留下未覆盖缺口对于 hour/day/week 授权tick 会把新窗口锚定到第一条未被覆盖的用量事件上。attach 会立即关闭 live 窗口但在下一条事件到来前没有窗口重新开启——在这两个瞬间之间feature 没有 live 窗口缺口内的用量不计入任何配额。已知缺口。缺口时长等于客户下一条事件到来前的时间。9. 必须保持的不变量Invariants任何触及这段代码的改动都应保持以下六条为真分段平铺successor.valid_from predecessor.valid_to始终成立。由OpenFeatureBasedEntitlementGrants中的硬错误强制对应源码中的successor window does not tile with the closed grant校验。授权行除valid_to外不可变CloseWindow只收缩窗口且完全不碰usage、grant_status、last_computed_at。正是last_computed_at valid_to让已关闭的行留在求值器的未定稿集合unfinalized set里做最后一次刷新使尾部事件tail events仍能到达计费读取。实现见 internal/repository/ent/entitlementgrant.go 的CloseWindow仓库方法——它只执行Update().SetValidTo(...)。没被测量过的窗口是删除而非拆分判据是last_computed_at而不是关闭边界。耗尽的窗口在 detach 时保持开启关闭它会让 tick 从幸存配置重新推导全新配额把池子已消耗的额度返还回来。窗口绝不回溯覆盖早于其配置存续期的用量grantCandidate.startDate携带任何贡献 EC 最早的存活时刻computeGrantWindow以它为coveredUntil的下限floor。Preview 不写任何数据。10. 测试覆盖与源码验证10.1 单元测试internal/ee/service/entitlement_grant_proration_test.goaddon attach/detach 的按比例分摊、窗口平铺与截断断言internal/domain/entitlementgrant/model_test.gogrant 领域模型校验internal/domain/proration/系数助手coefficient_helper_test.go与计算器测试。10.2 端到端验证覆盖 §7 与 §8 的 29 个场景在 live server 上运行、按场景隔离 fixtures直接断言从 Postgres 读出的entitlement_grants行。§8 中的行为刻意没有回归测试——它们是被期望变更的立场。只有在 §8.1 的 carry-forward 策略敲定后才需要用测试把它们钉死。10.3 配套的探针与系数实现internal/ee/e2eprobe/checks/entitlement_grant_additive_probe.goe2eprobe 场景验证订阅继承 plan 级 additive grant 权益并将事件聚合进非空用量摘要。系数实现要点internal/domain/proration/coefficient_helper.gocalculateProrationCoefficient支持second_based与day_based两种策略second-based 即remainingSeconds / totalSecondsday-based 在 DST 感知的daysInDurationWithDST基础上对起止日期各加 1 使其闭区间。信用授权按比例分摊credit-grant proration重构后复用同一个Coefficient入口消除了与价格按比例分摊price proration之间的代码重复这也印证了文档Refactored onto the shared coefficient helper的表述。10.4 存储与索引ent/schema/entitlement_grant.go 中两个索引值得留意生产读路径全部由其服务唯一索引(tenant_id, environment_id, entitlement_config_id, customer_id, subscription_id, valid_from)服务于 INSERT 竞态防护valid_from在用量锚定窗口下是确定性的两个 worker 打开同一槽位会在此碰撞与FindLastBySlot五列等值前缀 ORDER BY valid_from DESC LIMIT 1无需排序。复合索引(tenant_id, environment_id, customer_id, valid_to, entitlement_config_id, subscription_id)服务于每个 tick 的读取与计费重叠查询——等值列收窄到当前周期valid_to范围界定扫描范围。结语FlexPrice 的权益授权按比例分摊设计用一个jsonbmetadata 列加一套窗口平铺、行不可变、删除未测量窗口、耗尽窗口保持开启的行为约束解决了 addon 中途附加/解绑时配额授予的公平性问题。其核心在于把分摊动作提前到变更发生的当下用不可变的分段窗口表达时间上的公平用 metadata 留下完整的审计轨迹。配合 23 个端到端场景的验证与 6 个明确的产品决策这段实现既具备可靠的工程落地性也为未来的 carry-forward 与 clawback 策略预留了清晰的扩展点——对于任何构建 usage-based billing 与 feature entitlement 系统的开发者都是一份值得对照源码精读的参考实现。赞分享【免费下载链接】flexpriceUsage-based pricing and billing for developers Cloud or self-hosted ⚙️ No-code UI Realtime usage metering Credits top-ups Control feature access项目地址https://gitcode.com/gh_mirrors/fl/flexprice点击查看免费下载相关推荐Paseo Daemon 语义权限模型Principal、Grant 与九项细粒度权限的授权设计指南Paseo Daemon 语义权限模型Principal、Grant 与九项细粒度权限的授权设计指南 Paseo 的守护进程daemon通过一套语义化权限FlexPrice 订阅换绑Swap-In-Place方案设计Plan Change v2 ERD 深度解析FlexPrice 订阅换绑Swap In Place方案设计Plan Change v2 ERD 深度解析 本篇文章基于 FlexPrice 仓库中的设Flexprice 周期中按比例分摊费用发票幂等性设计基于操作范围的唯一性键方案Flexprice 周期中按比例分摊费用发票幂等性设计基于操作范围的唯一性键方案 导读 本文基于 Flexprice 开源仓库中的设计文档 2026 07 2上一篇3分钟搞定Windows激活KMS_VL_ALL_AIO智能激活工具完全指南下一篇使用 repowise security 扫描 Git 全历史泄露密钥从 Codex 斜杠命令到底层实现创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考