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:
@@ -58,7 +58,12 @@ func (h *GatewayHandler) CreateRefund(c *gin.Context) {
|
||||
func (h *GatewayHandler) GetRefund(c *gin.Context) {
|
||||
v, err := h.g.GetRefund(c.Param("refund_id"))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
|
||||
if errors.Is(err, store.ErrRefundNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
|
||||
return
|
||||
}
|
||||
log.Printf("[v2 refund] 查询退款单失败 %s: %v", c.Param("refund_id"), err)
|
||||
util.RespondError(c, http.StatusInternalServerError, "get_refund_failed", "查询失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, v)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -129,3 +130,131 @@ func TestManualRefundAdminFlow(t *testing.T) {
|
||||
t.Fatalf("order = %s want refunded", o.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundHTTPErrorMapping table-drives writeRefundErr(POST /refunds)与
|
||||
// CompleteRefund(POST /admin/refunds/:id/complete)的错误 → {status,code} 映射,
|
||||
// 断言 P4 final-review item 3 点名的四种场景。
|
||||
func TestRefundHTTPErrorMapping(t *testing.T) {
|
||||
payOrder := func(t *testing.T, g *gateway.Gateway, orders *store.OrderStore, fp *fake.Provider, orderNo string) {
|
||||
t.Helper()
|
||||
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
||||
var ref string
|
||||
for _, a := range atts {
|
||||
if a.OutTradeNo == orderNo {
|
||||
ref = a.ProviderRef
|
||||
}
|
||||
}
|
||||
fp.SetQueryResult(ref, provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
|
||||
if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T) *http.Response
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "未知订单 → 404 order_not_found",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, _, _, _ := buildRefundEngine(t)
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": "no-such-order", "amount_minor": 100, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: "order_not_found",
|
||||
},
|
||||
{
|
||||
// item 4:GetRefund 对 store.ErrRefundNotFound 精确映射 404 refund_not_found
|
||||
// (而非把所有查询失败都笼统 404)。
|
||||
name: "未知退款单号(GET /refunds/:id)→ 404 refund_not_found",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, _, _, _ := buildRefundEngine(t)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v2/refunds/no-such-refund-id", nil)
|
||||
return serve(t, r, req)
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: "refund_not_found",
|
||||
},
|
||||
{
|
||||
name: "订单未结算(仍 pending)→ 409 order_not_refundable",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, g, _, _ := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 100, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: "order_not_refundable",
|
||||
},
|
||||
{
|
||||
name: "超退金额(超过可退余额)→ 422 refund_amount_invalid",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, g, orders, fp := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-over"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
payOrder(t, g, orders, fp, res.OrderNo)
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000 + 1, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
wantCode: "refund_amount_invalid",
|
||||
},
|
||||
{
|
||||
name: "重复人工完成(已非 manual_pending)→ 409 not_manual",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
t.Setenv("PAY_ADMIN_TOKEN", "adm-tok")
|
||||
r, g, orders, fp := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-dup"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
payOrder(t, g, orders, fp, res.OrderNo)
|
||||
// fp 未 EnableRefund → manual_pending
|
||||
mr, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"})
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx"})))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Pay-Admin", "adm-tok")
|
||||
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first complete status = %d want 200", resp.StatusCode)
|
||||
}
|
||||
// 第二次 complete:refund 已 succeeded,非 manual_pending → 应 409。
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx-2"})))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Pay-Admin", "adm-tok")
|
||||
return serve(t, r, req2)
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: "not_manual",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp := c.run(t)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != c.wantStatus {
|
||||
t.Fatalf("status = %d want %d, body=%s", resp.StatusCode, c.wantStatus, body)
|
||||
}
|
||||
var out struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("decode body %s: %v", body, err)
|
||||
}
|
||||
if out.Code != c.wantCode {
|
||||
t.Fatalf("code = %q want %q, body=%s", out.Code, c.wantCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user