PlatformBilling 把“是否买了某项能力”和“消耗了多少资源”拆成 Entitlement、Feature、Quota、UsageRecord 四类事实。这个拆分是正确方向,但当前服务只提供解析和硬配额检查,并没有自动接入所有业务模块,也没有把用量换算成金额。
1. 三层准入模型
EntitlementResolver 的当前公式是:
Entitled = Plan 中存在 enabled FeatureCode OR Catalog 对当前 PlanId+PlanVersion 存在 enabled 映射
FeatureEnabled = IFeatureStore.IsEnabledAsync(featureCode, tenantId)返回的 EntitlementResolution 只保存两个布尔值,没有 EffectiveEnabled。消费模块必须明确决定通常的 Entitled && FeatureEnabled,再执行主体权限与数据范围;不要看到其中一个 true 就放行。
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 使用当前用户租户和当前应用时钟:
- 查当前订阅;
- 按订阅 PlanId 查套餐;
- 读取该租户+MeterCode 的全部事实并恢复 UsageMeter;
- 用累计 TotalQuantity 检查配额;
UsageMeter.Record校验 UsageId 非空、Quantity 大于 0;- 重复 UsageId 在聚合内返回成功但不新增;
- 仓储只追加尚未存在的事实行。
POST /api/platform-billing/usage HTTP/1.1Authorization: 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 逐条重放进内存聚合;写入时追加新增事实。
数据库唯一键是 (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 BillingQuotaBucketSET Used = Used + @requested, Version = Version + 1WHERE 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 总览 · 套餐与订阅 · 账单与幂等