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:
@@ -195,20 +195,24 @@ func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, method string,
|
||||
}
|
||||
|
||||
// finalizeCanceled 是取消的唯一落地点(主动取消 API + 入站 webhook 两路收敛于此):
|
||||
// MarkCanceled 幂等翻转 canceled;仅首次真正翻转(flipped)且有业务方(BizSystem 非空)
|
||||
// 才入队 subscription.canceled——重投/独立收款不重复发。
|
||||
// MarkCanceled 幂等翻转 canceled,随后(有业务方时)入队 subscription.canceled。
|
||||
//
|
||||
// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):若第一次翻转
|
||||
// 成功但入队瞬时失败,调用方拿到 err 后重投——此时 MarkCanceled 已是"已取消"故 flipped=
|
||||
// false,若像旧实现那样 `!flipped→return nil` 直接跳过,outbox 永远补不上这一行,通知永久
|
||||
// 丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:
|
||||
// 行已存在则 no-op,行曾丢失则本次补建。canceled 每订阅只发生一次,不存在"同订阅多次 canceled
|
||||
// 真被吞"的歧义(不同于 past_due 可能有多次不同 invoice 失败),无条件入队是安全的。
|
||||
//
|
||||
// outbox 键取真实首购单号 sub.OutTradeNo(不用合成后缀键):该单已 paid,Notifier 投递门禁
|
||||
// (orderPaid)天然放行;event_type=subscription.canceled 与该单已有的 payment.succeeded /
|
||||
// subscription.created 行不同 event_type,唯一键 (out_trade_no,event_type,refund_id) 不撞。
|
||||
// canceled 每订阅只发生一次,不存在"同订阅多次 canceled 被吞"的问题(不同于 past_due)。
|
||||
func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
|
||||
flipped, err := g.subs.MarkCanceled(sub.SubID)
|
||||
if err != nil {
|
||||
if _, err := g.subs.MarkCanceled(sub.SubID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flipped || sub.BizSystem == "" {
|
||||
return nil // 已取消(重投)/ 独立收款无业务方回调
|
||||
if sub.BizSystem == "" {
|
||||
return nil // 独立收款无业务方回调
|
||||
}
|
||||
return g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionCanceled, "", map[string]any{
|
||||
"event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
|
||||
@@ -221,9 +225,15 @@ func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
|
||||
// 当前 active 时翻转,已 past_due/已 canceled 不动),入队 subscription.past_due 供业务方提醒
|
||||
// 用户换卡。下期 invoice.paid 成功由 settleRenewal 的 Activate 自动恢复 active。
|
||||
//
|
||||
// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):首次翻转成功但
|
||||
// 入队瞬时失败时,重投这个同一事件会命中 flipped=false(已是 past_due),旧实现直接 return
|
||||
// 会让这条 past_due 通知永久丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT
|
||||
// DO NOTHING 天然幂等——重投即自愈。
|
||||
//
|
||||
// outbox 键取真实首购单号 sub.OutTradeNo(理由同 finalizeCanceled):Task5 最小可行接受
|
||||
// "同订阅多次 past_due 只报首次"(第二次失败的行撞 (out_trade_no,event_type) 唯一键、
|
||||
// EnqueueDelivery 幂等 no-op),Task 7 视需要再引入合成键 + Notifier 门禁旁路。
|
||||
// "同订阅多次 past_due(不同 invoice 各自失败)只报首次"这一语义限制不变——第二次真实失败的
|
||||
// 行同样撞 (out_trade_no,event_type) 唯一键、幂等 no-op,Task 7 视需要再引入合成键 + Notifier
|
||||
// 门禁旁路;这里修的只是"同一事件重投也补不回入队"这一段自愈缺口,不扩大既有限制的范围。
|
||||
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
|
||||
if err != nil {
|
||||
@@ -236,15 +246,17 @@ func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev
|
||||
if err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
if !flipped || sub.BizSystem == "" {
|
||||
return SettleDuplicate, nil
|
||||
if sub.BizSystem != "" {
|
||||
if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{
|
||||
"event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
|
||||
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
|
||||
"failed_at": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
}
|
||||
if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{
|
||||
"event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
|
||||
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
|
||||
"failed_at": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return SettleFailed, err
|
||||
if !flipped {
|
||||
return SettleDuplicate, nil
|
||||
}
|
||||
return SettleProcessed, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user