PlatformBilling 已经具备可调用的套餐与订阅生命周期:套餐先以草稿创建,经过发布后才能开通或切换;当前租户可以暂停、恢复、换套餐、续费和取消;JobHost 可以执行自动续费与宽限期清扫。它仍是“每租户一条当前订阅”的模型,没有订阅历史、预定变更、席位数量或同租户多订阅。
1. Plan 是完整商业快照
| 字段 | 当前语义与边界 |
|---|---|
Code / Name | 去首尾空格后必须保持原值;最长 64/120;拒绝控制字符 |
PlanVersion | 正整数商业修订号,与 ORM 乐观并发 Version 分列 |
MonthlyPrice | 非负 decimal(18,2),最多两位小数 |
Currency | 恰好三个大写英文字母;这是格式检查,不是完整 ISO 4217 目录 |
PricingModel | FlatRate、Metered 或 Hybrid |
TaxRate | 非负 decimal(5,4),最大 9.9999 |
Status | Draft、Published 或 Retired |
Entitlements | 最多 128 条;FeatureCode 非空、最长 128、区分大小写去重 |
Quotas | 最多 128 条;MeterCode 唯一、Limit 非负、Unit 必填 |
RateCard | 最多 128 条;每个 MeterCode 唯一,价格、包含量和溢出价均非负 |
权益、配额与费率卡分别持久化为 JSON 快照。创建和更新都提交完整集合;无效 JSON 或不符合不变量的持久化数据会阻止聚合恢复,而不是被悄悄替换为空集合。
// 商业配置集合在一次聚合创建调用中完成统一校验。var created = Plan.Create( code: "standard-cn", name: "Standard China", version: 3, // 商业版本,不是数据库并发 Version。 monthlyPrice: 299m, currency: "CNY", pricingModel: PricingModel.Hybrid, taxRate: 0.06m, entitlements: [ new Entitlement("platform.tickets", true), new Entitlement("platform.webhooks", true), ], quotas: [ new Quota("tickets.maxOpen", 5_000m, "count"), ], rateCard: [ new PricingRule("tickets.created", 0m, 10_000m, 0.05m, "count"), ]);
if (created.IsFailure) return created.Error;
// Create 只得到 Draft;发布前不能用于新订阅。return created.Value!;2. 套餐状态机与管理 API
- 只有 Draft 可以修改定义。
- 只有 Published 能用于
StartSubscription或ChangeSubscriptionPlan。 - Retired 拒绝新订阅和套餐切换,但已有订阅仍可读取该套餐并解析权益。
- 重复发布、草稿直接退役、退役后回退都会返回稳定 Conflict。
| Method 与 route | 用例 | 授权动作 |
|---|---|---|
POST /api/platform-billing/plans | 创建 Draft | platform-billing.plan.create |
PUT /api/platform-billing/plans/{planId} | 更新 Draft 完整定义 | platform-billing.plan.update |
POST /api/platform-billing/plans/{planId}/publish | Draft → Published | platform-billing.plan.update |
POST /api/platform-billing/plans/{planId}/retire | Published → Retired | platform-billing.plan.update |
GET /api/platform-billing/plans | 按搜索、状态分页 | platform-billing.plan.view |
GET /api/platform-billing/plans/{planId} | 读取单个套餐 | platform-billing.plan.view |
列表读路径支持状态筛选,并通过 ReadModelStore 下推分页;它不再等同于“列出全部已发布套餐”。
3. 生产种子并不会自动发布
PlatformBillingPlanSeedStep 以 SeedScope.ProductionSafe 写入 Free、Trial、Standard、Enterprise 四个稳定 ID。新种子行通过 Plan.Restore 进入 Draft;已有 Published/Retired 行会原样保留,种子不会把它们降回草稿,也不会覆盖其商业定义。
| PlanId | Code | USD/月 | 当前特别注意 |
|---|---|---|---|
plan_free | free | 0 | 初次种子后仍需显式发布 |
plan_trial | trial | 0 | 没有独立试用时间窗或自动转订阅语义 |
plan_standard | standard | 99 | 发布后才可开通 |
plan_enterprise | enterprise | 499 | 两个配额 Limit 都是 0 |
Trial 目前只是免费套餐目录项。Settings Registry 中的 platform.billing.trialDurationDays=14 没有被开通、续费或清扫用例读取,不能据此承诺 14 天自动到期。
4. 开通与当前订阅
POST /api/platform-billing/subscriptions 的处理顺序如下:
- 从
ICurrentUser.User.TenantId取得可信租户; - 要求
ITenantStore.IsActiveAsync为 true; - 读取目标 Plan,并要求其处于 Published;
- 把 PlanId 与 PlanVersion 写入新订阅;
- 依赖 TenantId 唯一索引保存当前订阅。
var result = await mediator.Send( new StartSubscriptionCommand("plan_standard"), cancellationToken);
if (result.IsFailure) return result.Error; // TenantNotActive、PlanNotFound、PlanNotPublished 或持久化失败。
// 当前源码在这里不设置首个账期 EndedAt。return Result.Success();GET /api/platform-billing/subscriptions/current 返回当前租户的唯一订阅。Application 在插入前没有先检查“已有订阅”,并发开通最终由 TenantId 唯一索引裁决;调用方是否得到稳定 Conflict,仍取决于持久化异常映射。
5. 当前公开的订阅操作
| Route | 行为 |
|---|---|
POST /subscriptions/current/pause | Active → Paused;重复暂停幂等 |
POST /subscriptions/current/resume | Paused 或 Grace → Active |
POST /subscriptions/current/change-plan | Active/Paused/Grace 立即覆盖 PlanId 与 PlanVersion;目标必须 Published |
POST /subscriptions/current/renew | 生成并签发发票;免费套餐标记 Paid,付费套餐创建支付订单;结束时间延后一个月 |
POST /subscriptions/current/cancel | Active/Paused/Grace → Cancelled,并把 EndedAt 写为当前时刻 |
所有管理命令使用 platform-billing.subscription.update,租户来自当前用户上下文,不接受请求体指定 TenantId。
Cancelled 与 Expired 是终态。ChangePlan 只拒绝终态。手动 Renew 也只拒绝终态,所以 Paused 可以续费并保持 Paused;而自动续费只扫描 Active/Grace。
6. 手动续费、自动续费与宽限期
手动和自动续费共享同一基本步骤:加载订阅与 Plan、生成 Draft、Issue、保存发票;免费套餐直接 MarkPaid,付费套餐调用 IPaymentGateway.CreatePaymentOrderAsync;然后把 EndedAt 从“未来结束时间或当前时间”起延后一个月。
JobHost 还注册两条订阅作业:
| Job | 默认开关 | 选择范围 | 失败行为 |
|---|---|---|---|
auto-renewal | Payment:AutoRenewal:Enabled=false | EndedAt 在未来三天内或已过期的 Active/Grace | 单项失败进入/保持 Grace,发布失败通知;整批返回首个失败 |
grace-sweep | Payment:GraceSweep:Enabled=false | Grace 且 EndedAt 早于“当前时间减 7 天” | 转 Expired,发布过期通知;整批返回首个失败 |
配置默认关闭,必须在 JobHost 显式启用并提供支付 Provider。作业对租户逐项处理,但没有认领租约或条件更新来阻止多实例同时处理同一订阅。
7. 当前仍需收口的商业与可靠性边界
7.1 首个账期与 Trial
Start 不写 EndedAt,Trial 设置也未接入。产品需要明确首个周期、试用期限、转付费选择、到期通知和失败后的数据访问级别,再把这些决定建成领域状态,而不是依靠套餐名称。
7.2 套餐切换没有历史与生效日
ChangeSubscriptionPlan 立即覆盖两个套餐字段,不保存旧套餐、计划生效日、价格快照、差价或 proration。降级是否延迟、在途用量按哪个版本计价、撤销怎样恢复,都没有可查询证据。
7.3 续费幂等键需要重新设计
- 自动续费的 purpose 使用
auto-renewal:{subscriptionId},而发票幂等键还包含当前yyyy-MMPeriod,因此不同月份不会命中同一键。缺口在于它没有持久化目标续费周期、订阅预期版本或 RenewalAttempt,无法把“发票、下单、订阅延长”绑定成一次可恢复操作。 - 手动续费把精确到秒的当前时间写进键;同一秒重试会折叠,不同秒重试会创建新发票。
SaveInvoiceAsync遇到“同键、不同 InvoiceId”时返回成功,但调用方继续使用本次未落库的 Invoice 对象创建支付订单并推进订阅。
因此生产门禁应增加跨月自动续费、并发重复、保存后崩溃、支付成功但订阅保存失败,以及“重复键返回既有发票”合同。理想幂等身份至少绑定订阅、账期、动作类型和可重放的业务请求 ID。
7.4 外部支付与数据库不是一个原子事务
支付网关下单和数据库状态更新无法放进同一 ACID 事务。当前流程没有显式 PaymentAttempt/Saga 来记录“发票已保存、网关已受理、订阅尚未推进”的中间状态。上线前需要对账、补偿和人工恢复手册,而不是把一次 Handler 成功当成端到端结算完成。
8. 测试与发布证据
至少固定以下合同:
- Draft/Published/Retired 的全部合法边和非法边;
- 种子新增保持 Draft,且不覆盖 Published/Retired;
- 只有 Published 能开通或切换,Retired 仍服务已有订阅;
- Active/Paused/Grace/Cancelled/Expired 的命令与 HTTP 错误合同;
- Start 的 Tenant 状态、唯一键竞争和首个 EndedAt 语义;
- 手动/自动续费在免费、付费、网关失败、保存失败下的结果;
- 多实例 Job 并发、跨月幂等和 GraceSweep 时间边界;
- SqlSugar 与 EF Core 对状态、JSON、唯一索引和乐观并发的等价行为。
9. 源码复核命令
# 套餐定义、发布状态与严格集合校验。rg -n "PlanStatus|ValidateDefinition|ValidateSubscribable|RateCard" \ src/Platform/PlatformBilling -g '*.cs'
# 当前全部订阅命令、状态迁移和结束时间写入点。rg -n "StartSubscription|PauseSubscription|ResumeSubscription|ChangeSubscriptionPlan|RenewSubscription|CancelSubscription|EndedAt" \ src/Platform/PlatformBilling -g '*.cs'
# 续费选择条件、幂等键和宽限期清扫。rg -n "auto-renewal:|manual-renewal:|FindExpiringSubscriptions|GraceSweep|SaveInvoiceAsync" \ src/Platform/PlatformBilling src/Hosts/BitzOrcas.JobHost -g '*.cs'