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:
@@ -6,6 +6,7 @@ package alipay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -55,7 +56,11 @@ func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amo
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err)
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", rsp.Error)
|
||||
// 渠道明确拒绝(如 code=40004 ACQ.TRADE_HAS_FINISHED):钱确定没退成,
|
||||
// wrap ErrRefundRejected 让 gateway.Refund 能安全标 failed 释放预留额度。
|
||||
// 与之相对,前面 TradeRefund 调用本身失败(超时/网络错误)不 wrap 此哨兵——
|
||||
// 那种情况渠道侧是否已实际退款不确定,不能标 failed(见 item 1)。
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, rsp.Error))
|
||||
}
|
||||
// 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。
|
||||
return refundID, provider.PaidSucceeded, nil
|
||||
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
sw "github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/wangjia/pay/internal/money"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
ali "github.com/wangjia/pay/internal/provider/alipay"
|
||||
)
|
||||
@@ -117,6 +119,10 @@ func TestAlipayRefundIdempotentNoFundChange(t *testing.T) {
|
||||
|
||||
// 渠道拒绝(如金额超出可退余额)返回 code!=10000 的失败响应 —— TradeRefund 应把
|
||||
// rsp.Error 包出去,refundRef 留空,状态归 PaidFailed。
|
||||
// TestAlipayRefundChannelRejected 的响应必须像 fakeAlipayRefund 一样用"支付宝侧"私钥
|
||||
// 签名——否则 DecodeNotification/TradeRefund 在验签阶段就报错,走的是传输错误分支
|
||||
// (rsp 根本拿不到),永远到不了下面要验证的 rsp.IsFailure() 分支,原始 fixture 未签名
|
||||
// 这个测试其实是假绿的(见 P4 item 2)。
|
||||
func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
@@ -128,7 +134,12 @@ func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
"sub_code": "ACQ.TRADE_HAS_FINISHED", "sub_msg": "交易已完结",
|
||||
}
|
||||
nodeJSON, _ := json.Marshal(node)
|
||||
resp := map[string]any{"alipay_trade_refund_response": json.RawMessage(nodeJSON)}
|
||||
h := sha256.Sum256(nodeJSON)
|
||||
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliKey, crypto.SHA256, h[:])
|
||||
resp := map[string]any{
|
||||
"alipay_trade_refund_response": json.RawMessage(nodeJSON),
|
||||
"sign": base64.StdEncoding.EncodeToString(sig),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
@@ -139,7 +150,30 @@ func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("want 渠道拒绝返回 error")
|
||||
}
|
||||
// 真正走到 rsp.IsFailure() 分支后,error 须满足 errors.Is(..., ErrRefundRejected)——
|
||||
// 与 item 1 配套:gateway.Refund 靠这个哨兵区分"渠道确定性拒绝"(可标 failed)
|
||||
// vs"结果不确定"(须留 processing)。
|
||||
if !errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
|
||||
}
|
||||
if status != provider.PaidFailed || ref != "" {
|
||||
t.Fatalf("refund result: ref=%q status=%s", ref, status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyFormatStripsTrailingZerosForAlipay 记录一个既定行为(防静默回归):
|
||||
// money.Format 对整元金额去尾零,19900 分(199.00 元)格式化成 "199" 而非 "199.00"。
|
||||
// 上面所有 alipay refund 用例传的 19900 分走的正是这条路径(见 p.Refund 内
|
||||
// money.Format(amountMinor,"CNY") 再作为 RefundAmount 传给 alipay.trade.refund)。
|
||||
// 支付宝接受这种去尾零形态的金额字符串,且与建单路径(Create → money.Format)同源、
|
||||
// 已被 TestParseFormatRoundTrip 覆盖一致性,这里单独钉一行防止 alipay refund 路径
|
||||
// 依赖的具体字符串形态被静默改掉。
|
||||
func TestMoneyFormatStripsTrailingZerosForAlipay(t *testing.T) {
|
||||
got, err := money.Format(19900, "CNY")
|
||||
if err != nil {
|
||||
t.Fatalf("money.Format(19900,CNY): %v", err)
|
||||
}
|
||||
if got != "199" {
|
||||
t.Fatalf("money.Format(19900,CNY) = %q want %q(既定去尾零行为)", got, "199")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,14 @@ type QueryRequest struct {
|
||||
var (
|
||||
ErrUnknownMethod = errors.New("provider: unknown method")
|
||||
ErrNotSupported = errors.New("provider: capability not supported")
|
||||
|
||||
// ErrRefundRejected — 渠道对退款请求的一个"确定性"拒绝(如金额超出可退余额、
|
||||
// 交易已完结等业务规则拒绝),而非超时/网络错误/5xx 这类结果不确定的失败。
|
||||
// RefundingProvider.Refund 的实现须在能确认渠道明确拒绝时,让返回的 error 满足
|
||||
// errors.Is(err, ErrRefundRejected)(errors.Join 或 %w 包裹均可);网关据此区分
|
||||
// "钱确定没退成可以标 failed 释放额度" vs "退没退不确定,必须留 processing 占额度
|
||||
// 交人工/对账收敛"(P6 RefundStuckAlertTask)。
|
||||
ErrRefundRejected = errors.New("provider: refund rejected by channel")
|
||||
)
|
||||
|
||||
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
|
||||
|
||||
@@ -7,6 +7,7 @@ package stripe
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -65,6 +66,14 @@ func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amoun
|
||||
params.SetIdempotencyKey(refundID)
|
||||
rf, err := p.sc.Refunds.New(params)
|
||||
if err != nil {
|
||||
// *stripe.Error + HTTPStatusCode 4xx = 渠道对这次请求的确定性拒绝(参数/状态
|
||||
// 类错误,如金额超出可退余额、PaymentIntent 已全额退过);5xx/网络错误(err 不是
|
||||
// *stripe.Error,或是但状态码 5xx)结果不确定,不 wrap ErrRefundRejected ——
|
||||
// 与 alipay 同一判定原则(见 provider.ErrRefundRejected 注释 / item 1)。
|
||||
var serr *gostripe.Error
|
||||
if errors.As(err, &serr) && serr.HTTPStatusCode >= 400 && serr.HTTPStatusCode < 500 {
|
||||
return "", provider.PaidFailed, fmt.Errorf("stripe: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, err))
|
||||
}
|
||||
return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err)
|
||||
}
|
||||
return rf.ID, mapRefundStatus(rf.Status), nil
|
||||
|
||||
Reference in New Issue
Block a user