Skip to content
bitzorcas
中EN

Guide

Billing 权益、Feature、配额与用量

讲清套餐权益与 Catalog 映射、Feature Store、配额判定、UsageId 幂等事实、并发边界和当前计价缺口。

Last updated

PlatformBilling 把“是否买了某项能力”和“消耗了多少资源”拆成 Entitlement、Feature、Quota、UsageRecord 四类事实。这个拆分是正确方向,但当前服务只提供解析和硬配额检查,并没有自动接入所有业务模块,也没有把用量换算成金额。

1. 三层准入模型

all required gates pass

Plan.Entitlements

plan OR catalog

Catalog entitlement mappings

Entitled

IFeatureStore.IsEnabled

FeatureEnabled

consumer decision

RBAC / ABAC / ReBAC

business use case

EntitlementResolver 的当前公式是:

Entitled = Plan 中存在 enabled FeatureCode
OR Catalog 对当前 PlanId+PlanVersion 存在 enabled 映射
FeatureEnabled = IFeatureStore.IsEnabledAsync(featureCode, tenantId)

返回的 EntitlementResolution 只保存两个布尔值,没有 EffectiveEnabled。消费模块必须明确决定通常的 Entitled && FeatureEnabled,再执行主体权限与数据范围;不要看到其中一个 true 就放行。

在业务用例入口组合权益与 Feature
public static class BillingErrors
{
public static readonly Error EntitlementDenied =
Error.Forbidden("Billing.Entitlement.Denied", "当前套餐未启用该能力。");
}
// 分别解析商业权益与租户运行时开关,不能把二者混成一个来源。
// Catalog 查询失败必须保留失败语义,不能降级为默认授权。
var resolution = await resolver.ResolveAsync(
tenantId: currentUser.User.TenantId,
plan: currentPlan,
featureCode: "platform.webhooks",
cancellationToken);
if (resolution.IsFailure)
return resolution.Error; // Catalog store 失败时保留失败,不能默认有权益。
var gate = resolution.Value!;
if (!gate.Entitled || !gate.FeatureEnabled)
return BillingErrors.EntitlementDenied;
// 权益通过后仍需执行当前用户权限和目标资源的数据范围检查。

2. 解析缓存的真实行为

缓存键是:

platform-billing:entitlement:{tenantId}:{planId}:v{planVersion}:{featureCode}

它包含 TenantId 与 PlanVersion,避免两个租户或两个套餐版本直接共用结果。但缓存只是 EntitlementResolver scoped 实例内的 Dictionary:

  • 不跨 HTTP 请求;
  • 不跨 API 节点;
  • 没有过期时间;
  • 同一 scope 内 Feature/Catalog 变更后不会失效;
  • 不是 FusionCache/Redis;
  • PlatformBillingOptions.EnableEntitlementCache=false 不会关闭它,因为 Resolver 没读取 Options。

因此旧文档不能把它描述为“分布式权益缓存”或“配置可关闭的缓存”。如果后续改为跨请求缓存,必须同时订阅 Plan、Catalog mapping 和 Feature 变更,并把租户、PlanVersion、FeatureCode 全部纳入失效范围。

3. 配额判定

Quota 只有 MeterCode、Limit、Unit。QuotaService.Check 按 MeterCode 找第一项:

  • 找到配额:Allowed = used + requested <= limit,超额时 Behavior=Reject;
  • 没找到配额:Allowed=true、Limit=decimal.MaxValue、Behavior=AllowAndRecord。

Unit 不参与计算。100 GB 与调用方上报的 100 bytes 在运行时都只是 decimal;单位一致性完全依赖 MeterCode 协议。每个计量项必须发布不可变的单位和归一化规则。

直接检查一次配额
// 调用前已把数量归一为 MeterCode 对应的约定单位。
// 返回值保留已用量、本次请求和上限,便于审计拒绝原因。
var check = quotaService.Check(
plan,
meter,
meterCode: "storage.gb",
quantity: 2.5m); // 调用方保证单位已经是 GB。
if (check.IsFailure)
return check.Error;
if (!check.Value!.Allowed)
{
logger.LogWarning(
"Quota rejected: used={Used}, requested={Requested}, limit={Limit}",
check.Value.Used,
check.Value.Requested,
check.Value.Limit);
}

4. RecordUsage 完整路径

POST /api/platform-billing/usage 接收 UsageId、MeterCode、Quantity,不接收 TenantId 和 OccurredAt。Handler 使用当前用户租户和当前应用时钟:

  1. 查当前订阅;
  2. 按订阅 PlanId 查套餐;
  3. 读取该租户+MeterCode 的全部事实并恢复 UsageMeter;
  4. 用累计 TotalQuantity 检查配额;
  5. UsageMeter.Record 校验 UsageId 非空、Quantity 大于 0;
  6. 重复 UsageId 在聚合内返回成功但不新增;
  7. 仓储只追加尚未存在的事实行。
上报一条可安全重试的用量
POST /api/platform-billing/usage HTTP/1.1
Authorization: Bearer <tenant-token>
Content-Type: application/json
{
"usageId": "document-upload:doc-8831:v1",
"meterCode": "storage.gb",
"quantity": 0.125
}

重试必须复用完全相同的 UsageId。不要每次重试都生成新 GUID,否则每次都会计量。推荐 {sourceType}:{sourceId}:{sourceVersion},并把同一 source version 的业务含义固定下来。

5. 持久化与幂等范围

UsageMeter 本身不作为单行存储。PlatformBillingUsagePersistenceAdapter 在读取时按 OccurredAt 排序,将 SysPlatformBillingUsageRecord 逐条重放进内存聚合;写入时追加新增事实。

Usage fact tableBilling RepositoryRecordUsage HandlerCallerUsage fact tableBilling RepositoryRecordUsage HandlerCallerUsageId + MeterCode + QuantityFindUsageMeter(tenant,meter)list existing factsall matching rowsreconstructed UsageMeterquota check + deduplicateSaveUsageMeterappend missing UsageId rows

数据库唯一键是 (TenantId, UsageId),比内存 Dictionary 的范围更宽:同一租户在不同 MeterCode 复用 UsageId 也会冲突。调用方应把 MeterCode 纳入 UsageId 或保证全租户唯一。

6. 并发配额不是原子的

当前是“读取全部事实 → 内存求和 → 判断 → 追加”的 read-modify-write。两个并发请求可能都读取 Used=99、Limit=100,各自增加 1 并同时通过;若 UsageId 不同,最终 Used=101。唯一索引只能阻止同 UsageId 重复,不能阻止不同用量并发超额。

生产可选方案:

  • 以 (TenantId,MeterCode,Period) 原子计数并使用条件更新;
  • 在事务中锁定 quota bucket;
  • 使用带 fencing token 的分布式预留;
  • 把高吞吐用量写入事件流,再通过可审计的预算 ledger 决策;
  • 区分 hard quota(同步拒绝)与 soft quota(允许、告警、后处理)。
目标:条件式配额预留的核心语义
-- 示意生产扩展,不是仓库现有 SQL。
UPDATE BillingQuotaBucket
SET Used = Used + @requested, Version = Version + 1
WHERE TenantId = @tenantId
AND MeterCode = @meterCode
AND Period = @period
AND Used + @requested <= Limit;
-- affected rows = 0 表示并发冲突或超额,随后重读并返回稳定 Conflict。

7. 当前没有账期维度

UsageRecord 没有 Period;QueryUsage 返回某租户某 MeterCode 的全部历史事实;QuotaService 也用全部历史 TotalQuantity。月度配额不会在每月重置,除非调用方更换 MeterCode 或另建清理逻辑。仓库没有清理/归档任务,也没有 page/filter 参数,大量事实会导致每次记录与查询不断增重。

要支持月度配额,应把 Period/WindowKey 固定进存储键和查询,定义时区、窗口起止、迟到事件、跨期更正和重算语义。仅在页面上按月份过滤不能修复写入时的配额决策。

8. 用量没有进入账单金额

GenerateMonthlyInvoiceCommand 没有读取 UsageMeter。用量事实中也没有:

  • 单价或 price tier;
  • 计价币种;
  • 套餐价格版本;
  • 免费额度消耗;
  • 折扣、税率、舍入规则;
  • 归属账期与关账状态;
  • invoice line id。

因此当前模块是“固定月费 + 独立硬配额”,不是 usage-based billing。要扩展计量计费,应先生成不可变 RatingResult/InvoiceLine 快照,而不是在查询发票时用最新价格重算历史。

9. 观测与隐私

建议指标至少按 Tenant/Meter 的安全维度聚合,避免高基数明文租户标签:

  • accepted/rejected/deduplicated usage count;
  • quota utilization histogram;
  • unknown meter count;
  • record latency 与事实表行数;
  • concurrent reservation conflict;
  • entitlement resolution failure/cache hit;
  • feature-vs-entitlement mismatch。

UsageId 可能携带业务资源 ID。日志与指标不应原样输出完整 UsageId;调试需要时使用 hash、短前缀或受控审计字段。

10. 测试与发布门禁

现有测试只证明同一 UsageId 在 fake repository 中去重、顺序超额被拒,以及缓存键包含 TenantId/PlanVersion。仍缺:

  • 不同 MeterCode 复用 UsageId 的数据库行为;
  • 双 ORM append-only 与唯一冲突映射;
  • 并发不同 UsageId 的配额超卖;
  • 月度/滚动窗口和迟到用量;
  • Catalog OR 语义与 Feature fail-closed;
  • scoped cache 过期/变更一致性;
  • 未配置配额、Limit=0、负数/小数/极大数;
  • 大体量读取、分页、归档和恢复;
  • RecordUsage 真实 HTTP 权限与跨租户隔离。

返回 Billing 总览 · 套餐与订阅 · 账单与幂等

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%