fix(v2): settleRenewal 重投补入队(通知不再永久丢)+ 无业务方首过返回 Processed(+同型路径核修)

settleRenewal 先 CreateRenewalPaid(建单已 paid)后 Enqueue,首次入队失败后重投走
created=false 的 duplicate 分支旧实现直接 return、不再入队,subscription.renewed
永久丢失(SyncPendingAttempts 救不了)。改为 duplicate 分支也无条件尝试入队,outbox
唯一键 ON CONFLICT DO NOTHING 幂等自愈;顺带对齐无业务方首过应返回 Processed。核查
发现 finalizeCanceled/markSubscriptionPastDue 有同型反纪律,同修。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 18:30:11 +08:00
parent 62abdc1d65
commit ff65a06bf2
4 changed files with 186 additions and 26 deletions
+27 -8
View File
@@ -138,7 +138,17 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider
// settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2
// (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。
// 幂等靠 renewal attempt 的 (channel,provider_ref=invoice.ID) 唯一索引 + renewal order 的
// out_trade_no 唯一索引双保险;出问题让 Stripe 重投,重投时 created=false 不重复入队,自愈
// out_trade_no 唯一索引双保险。
//
// 入队纪律(与文件头 Settle 的顺序不变量同根同源,但形态不同——续费建单是"单步已 paid",
// 没有 Settle 那种"翻转严格发生在成功入队之后"的两段式可依赖):**duplicate 分支(created=
// false)也必须尝试入队**,不能像旧实现那样直接 return。理由:首次入队若瞬时失败,订单/attempt
// 已在同一事务内落为 paid(不回滚,幂等键护着),而 outbox 从未有行;Stripe 拿不到 200 会重投
// 同一 invoice.paid,此时 created 必为 false——若 duplicate 分支不入队,该续费通知永久丢失
// (SyncPendingAttempts 也救不了:续费 attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
// outbox 唯一键 (out_trade_no,event_type,refund_id) 上 ON CONFLICT DO NOTHING 天然幂等:行已
// 存在则本次 no-op,行曾丢失则本次补建——重投即自愈,任何一分支入队失败都仍返回 SettleFailed
// 交渠道再重投。
func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
// channel = 触发本次回调的 method(与 markSubscriptionPastDue/recordChargeback 同式);
// 订阅诞生时 Subscription.Channel 落的正是 att.Channel=method,查询须对齐,不能硬编码字面量
@@ -174,18 +184,27 @@ func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider
if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil {
return SettleFailed, err
}
if !created || sub.BizSystem == "" {
return SettleDuplicate, nil // 重投 / 独立收款
// 无论 created 与否都尝试入队(见函数注释的入队纪律);独立收款(BizSystem=="")无下游可发,跳过。
if sub.BizSystem != "" {
if err := g.enqueueRenewed(sub, renewalNo, ev, paidAt); err != nil {
return SettleFailed, err // 已建单的事实不回滚(幂等键护着);渠道重投时补入队
}
}
if err := g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{
if !created {
return SettleDuplicate, nil // 重投:订单/attempt 已在(幂等 no-op),outbox 已补齐
}
return SettleProcessed, nil // 首过(含无业务方的独立收款,对齐 enqueuePaymentSucceeded 语义)
}
// enqueueRenewed 组 subscription.renewed 领域 payload 并幂等入队(抽出供 settleRenewal 的
// created/duplicate 两分支共用,避免复制两份 payload 构造)。
func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *provider.PaidEvent, paidAt time.Time) error {
return g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{
"event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel,
"paid_at": paidAt.Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
})
}
func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {