e26f995d7f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
212 lines
8.4 KiB
Go
212 lines
8.4 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/wangjia/pay/internal/model"
|
|
"github.com/wangjia/pay/internal/provider"
|
|
"github.com/wangjia/pay/internal/util"
|
|
)
|
|
|
|
var (
|
|
ErrOrderNotRefundable = errors.New("gateway: order not refundable (not settled or fully refunded)")
|
|
ErrRefundAmountInvalid = errors.New("gateway: refund amount invalid or exceeds refundable balance")
|
|
ErrRefundNotManual = errors.New("gateway: refund is not awaiting manual settlement")
|
|
)
|
|
|
|
type RefundInput struct {
|
|
OutTradeNo string
|
|
AmountMinor int64
|
|
Reason string
|
|
BizSystem string // 非空则须等于 order.BizSystem(归属校验);平台发起可空
|
|
InitiatedBy string // business/platform;空默认 business
|
|
}
|
|
|
|
type RefundResult struct {
|
|
RefundID string `json:"refund_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// Refund 业务发起退款:校验 → 经 store.CreateRefundGuarded 单事务(锁订单行 + SUM +
|
|
// 超退守卫 + 插入)建单 → 渠道退款(或 crypto 人工向)→ 幂等落状态 + 态机 + 事件。
|
|
//
|
|
// 超退守卫铁律:插入 Refund 行必须走 CreateRefundGuarded 的单事务 锁单+SUM+guard+insert,
|
|
// 绝不能用 RefundSum(...)-then-CreateRefund 两步序列——并发同单退款下两步之间会竞态
|
|
// (都读到 reserved=0、都通过校验、都插入 → 超退)。guard 用的"已付金额"取自本次已读到
|
|
// 的订单行 o.AmountMinor(而非调用方传入的任意数),防止上游拼错/被篡改的金额把守卫绕过。
|
|
func (g *Gateway) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) {
|
|
o, err := g.orders.GetOrder(in.OutTradeNo)
|
|
if err != nil {
|
|
return nil, err // ErrOrderNotFound
|
|
}
|
|
if in.BizSystem != "" && o.BizSystem != in.BizSystem {
|
|
return nil, ErrOrderNotRefundable // 非本业务的单
|
|
}
|
|
if !o.Status.Settled() || o.Status == model.OrderRefundedV2 {
|
|
return nil, ErrOrderNotRefundable
|
|
}
|
|
if in.AmountMinor <= 0 {
|
|
return nil, ErrRefundAmountInvalid
|
|
}
|
|
att, err := g.orders.PaidAttempt(in.OutTradeNo)
|
|
if err != nil {
|
|
return nil, err // ErrAttemptNotFound
|
|
}
|
|
if in.InitiatedBy == "" {
|
|
in.InitiatedBy = "business"
|
|
}
|
|
refundID := util.NewOutTradeNo("rf")
|
|
prov, err := g.providers.Get(att.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rp, canRefund := prov.(provider.RefundingProvider)
|
|
channelRefundable := canRefund && prov.Capabilities().SupportsRefund
|
|
|
|
// 建单态:渠道可退 → processing(即将调渠道 API);crypto 自托管等不可退渠道 →
|
|
// manual_pending(待运营人工向,钱未退)。两者都占用退款额度,都必须过守卫。
|
|
status := model.RefundManualPending
|
|
if channelRefundable {
|
|
status = model.RefundProcessing
|
|
}
|
|
r := &model.Refund{
|
|
RefundID: refundID, OutTradeNo: in.OutTradeNo, AttemptProviderRef: att.ProviderRef,
|
|
AmountMinor: in.AmountMinor, Currency: o.Currency, Reason: in.Reason,
|
|
Status: status, InitiatedBy: in.InitiatedBy,
|
|
}
|
|
ok, err := g.refunds.CreateRefundGuarded(r, o.AmountMinor) // paid amount = 订单行读到的金额,非调用方输入
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, ErrRefundAmountInvalid
|
|
}
|
|
|
|
if !channelRefundable {
|
|
// crypto 自托管:无退款 API → 建"待人工"单即返回(钱未退,不动 order/不发事件)。
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundManualPending)}, nil
|
|
}
|
|
|
|
// 渠道退款:refundID 作渠道幂等键。
|
|
refundRef, pstatus, rerr := rp.Refund(ctx, att.ProviderRef, refundID, in.AmountMinor, in.Reason)
|
|
if rerr != nil {
|
|
if errors.Is(rerr, provider.ErrRefundRejected) {
|
|
// 渠道明确拒绝:钱确定没退成,安全标 failed 并释放预留额度(RefundSum
|
|
// 守卫按 status<>failed 计,见 store.CreateRefundGuarded)。
|
|
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, "", time.Now())
|
|
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", "")
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, rerr
|
|
}
|
|
// 歧义渠道错误(超时/网络/5xx 等结果不确定的失败):不改状态,留 processing
|
|
// 继续占用预留额度——渠道那边可能已经实际执行了退款,标 failed 会在没有真退成
|
|
// 的情况下释放额度,让同单能再退一次造成超退,而且 P6 只扫 processing 态的
|
|
// RefundStuckAlertTask 对账/告警会因为状态被误标 failed 而永远扫不到它。
|
|
// 退款结果不确定,留 processing 待人工核实/对账收敛。
|
|
log.Printf("[gateway] refund %s 渠道结果不确定(非 ErrRefundRejected),留 processing 待人工/对账: %v", refundID, rerr)
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundProcessing)}, rerr
|
|
}
|
|
switch pstatus {
|
|
case provider.PaidSucceeded:
|
|
if err := g.settleRefundSucceeded(o, att, r, refundRef); err != nil {
|
|
return nil, err
|
|
}
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil
|
|
case provider.PaidFailed:
|
|
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, refundRef, time.Now())
|
|
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", refundRef)
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, nil
|
|
default: // PaidPending:stripe requires_action 等异步 → 保持 processing,交人工/P6 收敛
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundProcessing)}, nil
|
|
}
|
|
}
|
|
|
|
// settleRefundSucceeded 复刻 settle 的「先入队后翻转」:先幂等入队 refund.succeeded
|
|
// (dedupe=refund_id,同单多次部分退不撞键),再翻转 refund 单 + 推进 order 退款态机。
|
|
// 顺序不变量:refund 为 succeeded ⇒ outbox 行必已存在。
|
|
func (g *Gateway) settleRefundSucceeded(o *model.OrderV2, att *model.Attempt, r *model.Refund, refundRef string) error {
|
|
if err := g.enqueueRefundEvent(o, att, r, "refund.succeeded", refundRef); err != nil {
|
|
return err
|
|
}
|
|
if _, err := g.refunds.MarkRefundStatus(r.RefundID, r.Status, model.RefundSucceeded, refundRef, time.Now()); err != nil {
|
|
return err
|
|
}
|
|
succeeded, err := g.refunds.RefundSum(o.OutTradeNo, model.RefundSucceeded)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = g.orders.ApplyRefundToOrder(o.OutTradeNo, succeeded >= o.AmountMinor)
|
|
return err
|
|
}
|
|
|
|
func (g *Gateway) enqueueRefundEvent(o *model.OrderV2, att *model.Attempt, r *model.Refund, eventType, refundRef string) error {
|
|
if o.BizSystem == "" {
|
|
return nil // 独立收款无业务方回调
|
|
}
|
|
data := map[string]any{
|
|
"event_type": eventType,
|
|
"out_trade_no": o.OutTradeNo,
|
|
"biz_system": o.BizSystem,
|
|
"biz_ref": o.BizRef,
|
|
"product_biz_code": o.BizCode,
|
|
"refund_id": r.RefundID,
|
|
"provider_refund_ref": refundRef,
|
|
"amount_minor": r.AmountMinor,
|
|
"currency": r.Currency,
|
|
"channel": att.Channel,
|
|
"reason": r.Reason,
|
|
}
|
|
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, eventType, r.RefundID, data)
|
|
}
|
|
|
|
// CompleteManualRefund 收口 crypto 人工退款:运营链上转账后回填 tx,走与渠道成功同一落地路径。
|
|
func (g *Gateway) CompleteManualRefund(_ context.Context, refundID, providerRefundRef string) (*RefundResult, error) {
|
|
r, err := g.refunds.GetRefund(refundID)
|
|
if err != nil {
|
|
return nil, err // ErrRefundNotFound
|
|
}
|
|
if r.Status != model.RefundManualPending {
|
|
return nil, ErrRefundNotManual
|
|
}
|
|
o, err := g.orders.GetOrder(r.OutTradeNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
att, err := g.orders.PaidAttempt(r.OutTradeNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := g.settleRefundSucceeded(o, att, r, providerRefundRef); err != nil {
|
|
return nil, err
|
|
}
|
|
return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil
|
|
}
|
|
|
|
func (g *Gateway) ListManualPendingRefunds(limit int) ([]model.Refund, error) {
|
|
return g.refunds.ListManualPending(limit)
|
|
}
|
|
|
|
// RefundStatusView / GetRefund 供 HTTP 查询退款状态(Task 6)。
|
|
type RefundStatusView struct {
|
|
RefundID string `json:"refund_id"`
|
|
OutTradeNo string `json:"out_trade_no"`
|
|
AmountMinor int64 `json:"amount_minor"`
|
|
Currency string `json:"currency"`
|
|
Status string `json:"status"`
|
|
ProviderRefundRef string `json:"provider_refund_ref,omitempty"`
|
|
}
|
|
|
|
func (g *Gateway) GetRefund(refundID string) (*RefundStatusView, error) {
|
|
r, err := g.refunds.GetRefund(refundID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &RefundStatusView{
|
|
RefundID: r.RefundID, OutTradeNo: r.OutTradeNo, AmountMinor: r.AmountMinor,
|
|
Currency: r.Currency, Status: string(r.Status), ProviderRefundRef: r.ProviderRefundRef,
|
|
}, nil
|
|
}
|