fix(v2): P4 终审波——歧义渠道错误留 processing(ErrRefundRejected 哨兵)/ 拒绝fixture签名 / 错误映射测试 / GetRefund 日志
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
@@ -93,9 +94,20 @@ func (g *Gateway) Refund(ctx context.Context, in RefundInput) (*RefundResult, er
|
||||
// 渠道退款:refundID 作渠道幂等键。
|
||||
refundRef, pstatus, rerr := rp.Refund(ctx, att.ProviderRef, refundID, in.AmountMinor, in.Reason)
|
||||
if rerr != nil {
|
||||
_, _ = 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
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,7 @@ package gateway_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
@@ -115,3 +116,71 @@ func TestRefundManualForCryptoLikeChannel(t *testing.T) {
|
||||
t.Fatalf("complete webhook = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道明确拒绝(errors.Is(rerr, provider.ErrRefundRejected))→ 现行为:标 failed,
|
||||
// 释放预留额度(RefundSum 守卫按 status<>failed 计,故 failed 后同单能再退全款)。
|
||||
func TestRefundChannelRejectedMarksFailed(t *testing.T) {
|
||||
g, fp, spy, orders := newGateway(t)
|
||||
rejectErr := fmt.Errorf("channel: 交易已完结: %w", provider.ErrRefundRejected)
|
||||
fp.EnableRefund("", provider.PaidFailed, rejectErr)
|
||||
no := createAndPay(t, g, fp, orders)
|
||||
nCalls := len(spy.calls)
|
||||
|
||||
res, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin",
|
||||
})
|
||||
if !errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
|
||||
}
|
||||
if res == nil || res.Status != string(model.RefundFailed) {
|
||||
t.Fatalf("res = %+v want status=failed", res)
|
||||
}
|
||||
got, gerr := g.GetRefund(res.RefundID)
|
||||
if gerr != nil || got.Status != string(model.RefundFailed) {
|
||||
t.Fatalf("GetRefund = %+v, %v want status=failed", got, gerr)
|
||||
}
|
||||
if len(spy.calls) != nCalls+1 || spy.calls[len(spy.calls)-1]["event_type"] != "refund.failed" {
|
||||
t.Fatalf("渠道拒绝应发 refund.failed 事件, calls=%+v", spy.calls)
|
||||
}
|
||||
// 额度已释放(failed 不占 reserved)→ 同单再退全款不应被超退守卫拦下(即便这次
|
||||
// fp 仍预置拒绝、第二笔也会 failed,关键是不撞 ErrRefundAmountInvalid)。
|
||||
if _, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 29990000, BizSystem: "pangolin",
|
||||
}); errors.Is(err, gateway.ErrRefundAmountInvalid) {
|
||||
t.Fatalf("failed 退款不应继续占额度,再退全款不应被超退守卫拒: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 歧义渠道错误(超时/网络/5xx 等,不满足 errors.Is(..., ErrRefundRejected))→ 不得标
|
||||
// failed:留 processing 继续占预留额度,交人工/P6 RefundStuckAlertTask 对账收敛。
|
||||
func TestRefundAmbiguousChannelErrorStaysProcessing(t *testing.T) {
|
||||
g, fp, spy, orders := newGateway(t)
|
||||
ambiguousErr := errors.New("channel: dial tcp: i/o timeout")
|
||||
fp.EnableRefund("", provider.PaidFailed, ambiguousErr)
|
||||
no := createAndPay(t, g, fp, orders)
|
||||
nCalls := len(spy.calls)
|
||||
|
||||
res, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin",
|
||||
})
|
||||
if err == nil || errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want plain(non-rejected) error", err)
|
||||
}
|
||||
if res == nil || res.Status != string(model.RefundProcessing) {
|
||||
t.Fatalf("res = %+v want status=processing(留待人工/对账,不得标 failed)", res)
|
||||
}
|
||||
got, gerr := g.GetRefund(res.RefundID)
|
||||
if gerr != nil || got.Status != "processing" {
|
||||
t.Fatalf("GetRefund = %+v, %v want status=processing(不得改状态)", got, gerr)
|
||||
}
|
||||
// 歧义错误不应发 refund.failed 事件(渠道结果未定,不能对业务方宣告失败)。
|
||||
if len(spy.calls) != nCalls {
|
||||
t.Fatalf("歧义错误不应发退款事件, calls=%+v", spy.calls)
|
||||
}
|
||||
// 预留额度仍被占用(processing 计入 reserved)→ 同单再退到超额应被守卫拒。
|
||||
if _, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin",
|
||||
}); !errors.Is(err, gateway.ErrRefundAmountInvalid) {
|
||||
t.Fatalf("processing 应继续占额度,超额再退应被超退守卫拒, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user