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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user