diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index 8526656..6f5fd7e 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -111,7 +111,9 @@ func (h *GatewayHandler) Cancel(c *gin.Context) { } // Callback POST /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。 -// 已受理(含未知单/幂等/金额不符,都不需要渠道重投)一律回 200。 +// 按 SettleResult 分终态/可重试:not_found/duplicate/ignored/amount_mismatch 都是终态 +// (含 amount_mismatch —— Settle 对其返回非 nil err,但仍是已受理的终态),一律回 200 停投; +// SettleFailed(暂时性失败)与验签/解析失败(err!=nil 且非 amount_mismatch)是可重试态,回 400 让渠道重投。 func (h *GatewayHandler) Callback(c *gin.Context) { method := c.Param("method") raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)) @@ -126,13 +128,18 @@ func (h *GatewayHandler) Callback(c *gin.Context) { res, err := h.g.HandleCallback(c.Request.Context(), method, provider.CallbackInput{ Raw: raw, Headers: headers, }) - if err != nil { - // 验签失败等:回 400 让渠道按策略重投(或人工排障)。 + switch { + case err == nil: + c.JSON(http.StatusOK, gin.H{"result": string(res)}) + case res == gateway.SettleAmountMismatch: + // 金额/币种不符是终态,不是可重试的暂时性失败:回 200 受理,让渠道停止重投。 + log.Printf("[v2 callback] method=%s 金额/币种不符(终态,已受理停投): %v", method, err) + c.JSON(http.StatusOK, gin.H{"result": string(res)}) + default: + // SettleFailed/验签失败等:回 400 让渠道按策略重投(或人工排障)。 log.Printf("[v2 callback] method=%s result=%s err=%v", method, res, err) util.RespondError(c, http.StatusBadRequest, "callback_failed", "回调处理失败") - return } - c.JSON(http.StatusOK, gin.H{"result": string(res)}) } func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, err error) { diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index 84f6644..bced738 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -30,6 +30,12 @@ func (oneResolver) Resolve(sku string) (int64, string, string, string, error) { } func buildEngine(t *testing.T) *gin.Engine { + t.Helper() + r, _ := buildEngineWithStore(t) + return r +} + +func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) { t.Helper() gin.SetMode(gin.TestMode) orders := store.NewOrderStore(model.OpenTestDB(t)) @@ -41,7 +47,7 @@ func buildEngine(t *testing.T) *gin.Engine { g := gateway.New(orders, preg, areg, oneResolver{}, nopEnqueuer{}, "global") r := gin.New() router.SetupV2(r, g) - return r + return r, orders } func do(t *testing.T, r *gin.Engine, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) { @@ -99,3 +105,60 @@ func TestV2OrderLifecycle(t *testing.T) { t.Fatalf("cancel = %d %v", wCancel.Code, outCancel) } } + +// TestV2CallbackAmountMismatchIs200Terminal 覆盖 P2-7 审查发现:金额/币种不符是终态, +// 渠道无需(也不该)重投,回调必须回 200 停投,而不是 400 触发无限重投。 +func TestV2CallbackAmountMismatchIs200Terminal(t *testing.T) { + r, orders := buildEngineWithStore(t) + + w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"}) + if w.Code != http.StatusOK { + t.Fatalf("create code=%d body=%v", w.Code, out) + } + orderNo := out["data"].(map[string]any)["order_no"].(string) + + atts, err := orders.ListAttemptsByStatus(model.AttemptPending, 10) + if err != nil || len(atts) == 0 { + t.Fatalf("无 pending 尝试: %v", err) + } + var ref string + for _, a := range atts { + if a.OutTradeNo == orderNo { + ref = a.ProviderRef + } + } + if ref == "" { + t.Fatalf("未找到订单 %s 的尝试", orderNo) + } + + // 币种正确但少付(amount_minor=1 << 应收 29990000)→ amount_mismatch,终态,必须 200。 + wc, outc := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{ + "provider_ref": ref, "status": "succeeded", "amount_minor": 1, "currency": "USDT", + }) + if wc.Code != http.StatusOK { + t.Fatalf("amount_mismatch callback code=%d body=%v (终态应 200 停投,不应 400 触发无限重投)", wc.Code, outc) + } + if outc["result"] != string(gateway.SettleAmountMismatch) { + t.Fatalf("result = %v, want amount_mismatch", outc["result"]) + } + + // 订单应仍是 pending(未被误标为已支付)。 + _, outStatus := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil) + if outStatus["data"].(map[string]any)["status"] != "pending" { + t.Fatalf("order status = %v, want pending", outStatus["data"]) + } +} + +// TestV2CallbackMalformedBodyIs400Transient 验签/解析失败是瞬时态,渠道应重投,故回 400。 +func TestV2CallbackMalformedBodyIs400Transient(t *testing.T) { + r := buildEngine(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v2/callback/fake", bytes.NewBufferString("not-json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("malformed body callback code=%d, want 400", w.Code) + } +}