feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received
This commit is contained in:
@@ -3,7 +3,6 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -207,8 +206,59 @@ func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *
|
||||
})
|
||||
}
|
||||
|
||||
// recordChargeback 处理入站 charge.dispute.created(P8 Task6,设计 §6「钱到账不可逆」的
|
||||
// 例外形态)。只 Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款永无
|
||||
// chargeback——三者均不产出 EventChargeback,本函数只会被 stripe adapter 触发。
|
||||
//
|
||||
// 决策记录:不自动回收权益(§6,业务方裁量),只做三件事——①落 Chargeback(幂等 by
|
||||
// dispute_ref,DisputeRef 重投 no-op,不双记)②给原订单打 Disputed 标(不改状态机)
|
||||
// ③入队 chargeback.received 给业务方自行冲正。out_trade_no 解析失败(订阅拒付/查单失败)
|
||||
// 时仍落 Chargeback 留痕 + log 告警,但不阻断、不转发(无法定位业务单)。
|
||||
//
|
||||
// 入队不按 created 分叉(与 settleRenewal/finalizeCanceled/markSubscriptionPastDue 同型的
|
||||
// P8 反纪律修法):Chargeback.Create 与 Enqueue 是两次独立写,不在同一事务——若首次 Create
|
||||
// 成功但 Enqueue 瞬时失败,调用方(HandleCallback)拿到 err 后 Stripe 会重投同一
|
||||
// charge.dispute.created,此时 created 必为 false;若像"created=false→直接 return
|
||||
// SettleDuplicate"那样跳过下面的打标+入队,chargeback.received 通知永久丢失、Disputed
|
||||
// 标也永远打不上。改为无论 created 与否都走完打标+入队,outbox 唯一键 ON CONFLICT DO
|
||||
// NOTHING + MarkDisputed 条件 UPDATE 天然双重幂等——重投即自愈,不会双记/双发。
|
||||
func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
||||
created, err := g.chargebacks.Create(&model.Chargeback{
|
||||
DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method,
|
||||
ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor,
|
||||
Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received",
|
||||
})
|
||||
if err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
result := SettleProcessed
|
||||
if !created {
|
||||
result = SettleDuplicate // 拒付重投:Chargeback 已记录过,但仍需补齐下面的打标/入队(见函数注释)
|
||||
}
|
||||
if ev.OutTradeNo == "" {
|
||||
log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef)
|
||||
return result, nil
|
||||
}
|
||||
o, err := g.orders.GetOrder(ev.OutTradeNo)
|
||||
if err != nil {
|
||||
log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err)
|
||||
return result, nil // 已记录 chargeback;定位失败不阻断
|
||||
}
|
||||
if _, err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机;条件 UPDATE 幂等
|
||||
return SettleFailed, err
|
||||
}
|
||||
if o.BizSystem == "" {
|
||||
return result, nil
|
||||
}
|
||||
if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, "", map[string]any{
|
||||
"event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef,
|
||||
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
|
||||
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason,
|
||||
"received_at": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncPendingAttempts polls every pending attempt via its Provider.Query and
|
||||
|
||||
Reference in New Issue
Block a user