PlatformBilling 在 JobHost 登记 auto-renewal、grace-sweep 和 reconciliation 三个 Quartz 任务。三个 Job 都有 [DisallowConcurrentExecution],并通过统一 QuartzJobExecutionAuditor 执行 owner-local executor,但这不等于收款、权益延长、过期处置和差异处置已经闭环。
1. 调度配置
| 作业 | 开关 | 默认 Cron | 额外参数 |
|---|---|---|---|
| auto-renewal | Payment:AutoRenewal:Enabled | 0 0 1 * * ? | ExpiryWindowDays=3;ProviderCode 从 Payment:Provider 注入 |
| grace-sweep | Payment:GraceSweep:Enabled | 0 0 2 * * ? | GraceWindowDays=7 |
| reconciliation | Payment:Reconciliation:Enabled | 0 0 6 ? * MON | 无查询窗口/批量大小参数 |
三个 Enabled 默认 false。未启用时 executor 直接 Success。JobHost 同时注册 PlatformBillingRepository 和支付 gateway;未配置 provider 时支付与对账端口 fail-closed。
[DisallowConcurrentExecution] 保护 Quartz 认知中的同一 JobDetail;多实例是否互斥取决于 Quartz store/cluster 配置。Billing executor 自身没有分布式 lease/fencing token,发布前必须以实际部署拓扑验证。
2. 自动续费的真实路径
Repository 扫描:
Status in (Active, Grace)AND EndedAt IS NOT NULLAND EndedAt <= now + ExpiryWindowDays每条订阅依次执行:
- 读取 Plan,失败则 EnterGrace;
- 以当前
yyyy-MM、purpose=auto-renewal:{subscriptionId}生成发票; - 把 Draft 推进为 Issued 并保存;
- 免费套餐:MarkPaid 并再次保存;
- 付费套餐:CreatePaymentOrder;失败则 EnterGrace;
- 以
max(EndedAt, now)+1 month计算新结束时间; - Renew 订阅并保存;
- 普通
Result失败会尝试 EnterGrace,继续处理余下订阅,并在批次结束时返回首个失败;未预期异常与通知发布异常会向上抛出并中止本次执行。
3. 自动续费的 GA 阻断项
3.1 下单成功就延长权益
CreatePaymentOrder 成功只说明 provider 接受了订单,不说明客户已经支付。Job 仍立即 Renew。这会形成“未收款但权益已延长”。支付回调只是之后把 Invoice 标记 Paid,并不负责 Renew。
应改为以下一种明确模型:
- 预付:创建订单后保持 Grace/Pending,InvoicePaid consumer 再 Renew;
- 授信后付:先 Renew,但记录 CreditDecision、到期日和催收/冻结规则;
- 自动扣款:只有 provider 返回最终 captured/succeeded 才 Renew。
3.2 PaymentOrderHandle 被丢弃
AutoRenewal 不保存 PayUrl、QrCode、PrepayId、provider trade id 或 PaymentAttempt,也没有把支付入口发送给租户。对于需要用户交互的 Web/Code 场景,订单即使创建成功也没人能完成支付。
3.3 普通订阅不会进入扫描
StartSubscription 创建 EndedAt=null,扫描明确要求非空。仓库没有公开初始化首个 EndedAt 的用例,因此由普通 API 开通的订阅不会自动续费。
3.4 批次会保留首个失败,但还没有逐项执行凭据
普通 Result 失败不再被伪装为整批 Success。executor 会继续处理后续订阅,最后返回首个失败;EnterGrace 落库失败也会传播。未预期异常和通知发布异常则直接抛出,后续订阅不会再处理。
这比静默成功更可靠,但批次仍没有 claim、checkpoint 或逐项持久执行记录。调度器重试时只能重新扫描,无法准确区分“未开始、已下单、已续期、通知失败”等中间状态。
3.5 自动续费发票幂等键不含账期
发票 purpose 固定为 auto-renewal:{subscriptionId}。PlatformInvoice 的幂等键由 TenantId、Period 和 purpose 组合,因此正常月份之间仍可区分;但 executor 没有以“目标账期 + 订阅版本”持久化 RenewalAttempt,无法证明下单与 Renew 的跨进程原子性。尤其在发票或订单已成功、订阅保存失败时,重跑的恢复语义并不完整。
3.6 免费续费不发布 Paid 事件
免费套餐直接 MarkPaid/Save,不经 PaymentGatewayAdapter,因此没有 InvoicePaidIntegrationEvent。下游若依赖 Paid 事件生成权益/报表,免费与付费路径会分叉。
4. 更安全的续费状态编排
生产实现需要把 RenewalAttempt 持久化,唯一键至少含 SubscriptionId+目标周期;记录 expected EndedAt/Version,避免 Job 重跑重复延长一个月。发票幂等不能单独证明订阅 Renew 幂等,因为 Renew 本身没有 attempt key。
5. 宽限期清扫的真实语义
grace-sweep 查询以下订阅:
Status == GraceAND EndedAt IS NOT NULLAND EndedAt <= now - GraceWindowDays每条记录先执行 Expire(now) 并保存,再发布 platform-billing.subscription.expired。普通 Result 失败会保留首个错误并继续扫描;仓储查询、未预期异常或通知发布异常会让作业失败。
这条路径已经有“禁用时跳过、到期并通知、保存失败、仍在窗口内不处理”的单元测试,但还没有多实例 claim/lease、批量上限、分页、checkpoint 或失败项持久队列。另一个需要产品确认的边界是:当前 ResumeSubscription 可直接把 Grace 恢复为 Active,并不要求先验证付款。生产环境不能只依赖定时清扫来证明续费已完成。
6. 对账任务只报告差异
Reconciliation 依次扫描所有 Issued 和 Overdue 发票,对每张用 IdempotencyKey 查询 provider:
- 查询失败:Warning,differences+1;
- provider 未找到:Warning,differences+1;
- provider Success 但平台未 Paid:Warning,differences+1;
- Success 且金额不同:再 Warning,differences+1;
- 最后只记录 DifferenceCount 与时间并返回 Success。
它不会 MarkPaid、保存差异、发通知、创建工单、冻结订阅、分页、限速或记录 checkpoint。一个“成功且金额不同”的发票会计为两个 difference。Adapter 仍固定除以 100,且没有比较币种。
var result = await executor.ExecuteAsync(cancellationToken);
if (result.IsFailure){ // 只有扫描仓储整体失败等情况会使 Job 失败。 return result.Error;}
// Success 只表示扫描执行完毕;日志中可能仍有大量差异。// 当前 Result 不返回 DifferenceCount,也没有持久化报告可供 API 查询。return Result.Success();7. 对账应形成可处置工作流
一个可运营的 ReconciliationRun 至少保存:run id、provider/account、查询窗口、开始/结束时间、扫描数、匹配数、差异数、失败数、checkpoint、软件版本。每条 ReconciliationDifference 保存平台/供应商金额和状态、币种、交易号、分类、证据 hash、owner、SLA、处置状态与审计。
自动补记只适用于证据充分且幂等的类型,例如 provider 明确成功、订单/商户/金额/币种一致、平台仍为 Issued。金额不符、未知交易、已作废后付款必须人工复核,不能由 Job 猜测修正。
8. 可观测性与告警
至少建立:
| 指标/日志 | 发布阈值或用途 |
|---|---|
| renewal scanned/succeeded/grace/exception | 识别批次静默部分失败 |
| grace scanned/expired/failure/notification | 宽限超时与过期处置 |
| payment order latency/error by provider | provider SLO 与熔断 |
| subscription renewed before paid | 目标应为 0(预付模型) |
| issued/overdue age histogram | 账单状态停留 |
| callback verified/rejected/duplicate | 安全与回调健康 |
| reconciliation scanned/differences/unresolved | 差异积压与 SLA |
| InvoicePaid outbox lag | 收款到权益/下游传播延迟 |
| Job last success/duration/next fire | 调度健康 |
不要把 TenantId、InvoiceId、OutTradeNo 作为无限高基数 metric label;它们放入受控结构化日志或审计记录。
9. 故障处置
续费订单大量失败
先停止/禁用 auto-renewal,确认 provider、商户、密钥、证书、网络和限额;查询已创建订单,避免无脑重复下单;按 RenewalAttempt 幂等重跑。当前没有 attempt ledger,必须先导出发票/订阅/日志证据再人工判断。
回调成功率突然下降
按 provider 和错误类别区分验签、状态、金额、未找到、落库/Outbox。不要只看 HTTP 2xx,因为硬失败也返回 2xx。用 provider 查询接口核验实际支付,并评估“确认成功但未入账”的时间窗。
对账发现 provider 已付、平台未付
冻结自动作废/催收,核对 merchant、OutTradeNo、Amount、Currency、provider trade id 与回调证据。补记必须走与回调相同的条件状态更新和 Outbox 事务,并记录操作人/原因。
Job 重跑后订阅多延长一个月
当前 Renew 没有 attempt 幂等键。暂停续费,按发票 purpose/period 与订阅 EndedAt 对账;不要直接回写时间而不保留调整记录。修复应引入目标周期与 expected version。
10. 现有测试证据
有:订阅/发票状态机、Plan 更新、月账单顺序幂等、用量顺序去重/超额、Entitlement cache key、InvoiceIdempotency 组合、读模型 Handler 委托、owner-local 持久化架构和种子测试;AutoRenewal 覆盖发票保存、免费 Paid 保存、订阅保存失败与通知异常;GraceSweep 覆盖禁用、过期通知、保存失败和窗口边界。
没有:
- PaymentGatewayAdapter/PaymentEndpointGroup/provider fixtures;
- Reconciliation executor;
- Quartz/JobHost 调度组合;
- 发票+CAP 事务故障注入;
- 双 ORM 真实 PlatformBillingRepository 行为(仅共享端口/架构守护);
- 十九条真实 HTTP 路由与权限;
- 并发发票、用量、回调、续费;
- provider sandbox、密钥轮换和灾难恢复;
- 规模、分页、批处理和限流基线。
11. 推荐测试金字塔
- 领域:Plan/Subscription/Invoice/Usage 边界与状态表;
- 应用:每个 Command/Query 的 Result、租户、权限、事务副作用;
- 适配器契约:三 provider 金额/场景/回调 byte fixture;
- 持久化:双 ORM 唯一索引、并发 Version、append-only 与租户过滤;
- 消息:InvoiceIssued/Paid byte contract、Outbox 重试、重复消费;
- Host:十九条路由、Problem Details、匿名回调、限流/timeout;
- Job:三个作业的扫描窗口、部分失败、幂等重跑、多实例互斥;
- E2E:provider sandbox 的下单→回调→Paid→权益续期;
- 运维:provider outage、DB/CAP 故障、回调积压、对账恢复演练;
- 性能:大租户用量、发票查询、批量续费和 provider rate limit。
12. 商业 GA 门禁
- 产品计价模型、账期、币种、舍入、税/折扣与退款边界获批;
- Subscription 首周期、到期日、试用、变更、取消和历史证据完整;
- 预付/后付语义明确,续费不会错误授予未付款权益;
- PaymentAttempt、callback inbox、provider trade id 与并发幂等落地;
- 回调 provider/merchant/amount/currency/order 一致性和密钥轮换通过;
- 发票状态、支付事实、Outbox 和权益延长的事务/补偿边界通过故障注入;
- 对账有持久报告、告警、owner、SLA、补偿和人工处置;
- 配额原子性、账期窗口、归档和大体量性能通过;
- 权限、Feature、DataScope、审计和隐私保留生效;
- 双 ORM、三 provider、API、消息、Job、恢复与压测证据可追溯;
- Runbook、仪表盘、on-call 和 provider sandbox/生产切换清单完成。
按当前源码,PlatformBilling 的领域骨架和回调事务具备开发基础,但自动续费、对账和支付并发证据不足,不能宣告商业计费 GA。
13. 评审命令
# 暴露续费资格、订单创建、立即 Renew 与对账只记录日志的路径。rg -n "FindExpiringSubscriptions|FindGraceSubscriptions|CreatePaymentOrderAsync|Renew\(|DifferenceCount|LogWarning" \ src/Platform/PlatformBilling src/Hosts/BitzOrcas.JobHost -g '*.cs'
# 当前预期:AutoRenewal 与 GraceSweep 有专用测试;对账、支付适配器和端点仍没有。rg -n "PaymentGatewayAdapter|AutoRenewalJobExecutor|GraceSweepJobExecutor|ReconciliationJobExecutor|PaymentEndpointGroup" \ tests -g '*.cs'返回 Billing 总览 · 支付与回调 · 账单与幂等