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:
wangjia
2026-07-10 17:58:42 +08:00
parent 346004adf3
commit e26f995d7f
8 changed files with 277 additions and 6 deletions
+15 -3
View File
@@ -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:
+69
View File
@@ -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)
}
}