diff --git a/backend/internal/integrations/payment/lakala/client.go b/backend/internal/integrations/payment/lakala/client.go index b4ddab4..8ef3dfd 100644 --- a/backend/internal/integrations/payment/lakala/client.go +++ b/backend/internal/integrations/payment/lakala/client.go @@ -146,6 +146,7 @@ type QueryRefundResponse struct { RefundAmount string RefundTime string Raw map[string]string + RawRequest map[string]string } type VerifyNotifyResult struct { @@ -275,7 +276,14 @@ func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*Cr rawReq := stringifyMap(reqData) raw, err := c.post(ctx, endpointRefund, reqData) if err != nil { - return nil, err + return &CreateRefundResponse{ + MerchantID: c.cfg.MerchantID, + ThirdOrderID: req.ThirdOrderID, + ProviderOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + RefundAmount: strconv.FormatInt(req.RefundAmountCent, 10), + RawRequest: rawReq, + }, err } return &CreateRefundResponse{ OK: responseOK(raw), @@ -312,9 +320,17 @@ func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*Quer if c.cfg.TermNo == "" { delete(reqData, "term_no") } + rawReq := stringifyMap(reqData) raw, err := c.post(ctx, endpointRefundQuery, reqData) if err != nil { - return nil, err + return &QueryRefundResponse{ + MerchantID: c.cfg.MerchantID, + ThirdOrderID: req.ThirdOrderID, + ProviderOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + ProviderRefundID: req.ProviderRefundID, + RawRequest: rawReq, + }, err } return &QueryRefundResponse{ OK: responseOK(raw), @@ -328,6 +344,7 @@ func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*Quer RefundAmount: raw["refund_amount"], RefundTime: firstNonEmpty(raw["refund_time"], raw["trade_time"], raw["finish_time"]), Raw: raw, + RawRequest: rawReq, }, nil } diff --git a/backend/internal/integrations/payment/lakala/client_test.go b/backend/internal/integrations/payment/lakala/client_test.go index 28a2bf6..6359c83 100644 --- a/backend/internal/integrations/payment/lakala/client_test.go +++ b/backend/internal/integrations/payment/lakala/client_test.go @@ -159,6 +159,53 @@ func TestCreatePaymentUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) { } } +func TestCreateRefundReturnsRawRequestOnHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != endpointRefund { + t.Fatalf("path = %s, want %s", r.URL.Path, endpointRefund) + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"OP90001","msg":"请求服务失败"}`)) + })) + defer server.Close() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + privatePEM, certPEM := testKeyPairPEM(t, key) + client := NewClient(Config{ + GatewayURL: server.URL, + AppID: "app-1", + SerialNo: "serial-1", + MerchantID: "merchant-1", + TermNo: "term-1", + PrivateKey: privatePEM, + NotifyCert: certPEM, + }) + resp, err := client.CreateRefund(context.Background(), CreateRefundRequest{ + ThirdOrderID: "PAY123", + ProviderOrderID: "TRADE123", + MerchantRefundID: "REF123", + RefundAmountCent: 99, + RefundReason: "结账退款", + }) + if err == nil { + t.Fatal("CreateRefund error = nil, want HTTP error") + } + if resp == nil { + t.Fatal("CreateRefund response = nil, want raw request") + } + if resp.RawRequest["merchant_no"] != "merchant-1" || + resp.RawRequest["term_no"] != "term-1" || + resp.RawRequest["out_trade_no"] != "REF123" || + resp.RawRequest["refund_amount"] != "99" || + resp.RawRequest["origin_out_trade_no"] != "PAY123" || + resp.RawRequest["origin_trade_no"] != "TRADE123" { + t.Fatalf("raw request = %#v", resp.RawRequest) + } +} + func parseLakalaTestTime(t *testing.T, value string) time.Time { t.Helper() parsed, err := time.ParseInLocation("20060102150405", value, timeutil.ShanghaiLocation()) diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index 2c8b3c8..4f57703 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -67,7 +67,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou } if runtimeConfig.Channel == nil { - if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}); err != nil { + if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}, nil); err != nil { log.Printf("[payment] mark refund failed status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, err) } return nil, ErrPaymentUnavailable @@ -82,7 +82,11 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou Remark: remark, }) if err != nil { - if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}); markErr != nil { + var rawRequest map[string]string + if resp != nil { + rawRequest = resp.RawRequest + } + if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}, rawRequest); markErr != nil { log.Printf("[payment] mark refund failed status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, markErr) } log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", @@ -90,7 +94,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou return nil, err } if !resp.OK { - if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw); markErr != nil { + if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw, resp.RawRequest); markErr != nil { log.Printf("[payment] mark refund rejected status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, markErr) } log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", @@ -253,6 +257,11 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme } resp, err := runtimeConfig.Channel.QueryRefund(ctx, refundQueryRequest(*payment, originalPayment)) if err != nil { + if resp != nil && resp.RawRequest != nil { + if updateErr := r.updateRefundRawRequest(ctx, payment.ID, resp.RawRequest); updateErr != nil { + log.Printf("[payment] update refund query raw request failed payment_id=%d err=%v", payment.ID, updateErr) + } + } return nil, err } if !resp.OK { @@ -355,14 +364,24 @@ func (r *Repository) markOrderRefundFailed(ctx context.Context, orderID uint64, "refunded_at": nil, }).Error } -func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error { +func (r *Repository) updateRefundRawRequest(ctx context.Context, paymentID uint64, rawRequest map[string]string) error { + if rawRequest == nil { + return nil + } + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Update("raw_request", jsonMap(rawRequest)).Error +} +func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string, rawRequest map[string]string) error { if raw == nil { raw = map[string]string{"error": "refund failed"} } - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ + updates := map[string]any{ "status": "failed", "raw_response": jsonMap(raw), - }).Error; err != nil { + } + if rawRequest != nil { + updates["raw_request"] = jsonMap(rawRequest) + } + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error; err != nil { return err } return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 3514e52..e028f4c 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -1,6 +1,8 @@ package payment import ( + "context" + "encoding/json" "testing" "time" @@ -114,6 +116,53 @@ func createPayableOrderFixture(t *testing.T, db *gorm.DB, suffix string, createP return f } +func TestMarkRefundFailedPersistsRawRequest(t *testing.T) { + db := setupPaymentTestDB(t) + f := createPayableOrderFixture(t, db, "RAWREQ", true) + refund := model.PaymentOrder{ + PaymentNo: "PAYREFRAWREQ", + OrderID: f.Order.ID, + OrderNo: f.Order.OrderNo, + UserID: f.Renter.ID, + Provider: "lakala", + MerchantID: "merchant-1", + ThirdOrderID: "REFRAWREQ", + ProviderOrderID: "", + AmountCent: 99, + BizType: "checkout_refund", + Status: "refunding", + } + if err := db.Create(&refund).Error; err != nil { + t.Fatalf("create refund payment failed: %v", err) + } + + repo := NewRepository(db, nil, nil) + rawRequest := map[string]string{ + "merchant_no": "merchant-1", + "out_trade_no": "REFRAWREQ", + "refund_amount": "99", + "origin_trade_no": "TRADE123", + } + err := repo.markRefundFailed(context.Background(), refund.ID, f.Order.ID, refund.AmountCent, map[string]string{"error": "lakala http status 400"}, rawRequest) + if err != nil { + t.Fatalf("mark refund failed: %v", err) + } + + var saved model.PaymentOrder + if err := db.First(&saved, refund.ID).Error; err != nil { + t.Fatalf("find refund payment failed: %v", err) + } + var got map[string]string + if err := json.Unmarshal(saved.RawRequest, &got); err != nil { + t.Fatalf("decode raw_request: %v", err) + } + for key, want := range rawRequest { + if got[key] != want { + t.Fatalf("raw_request[%s] = %q, want %q; full=%v", key, got[key], want, got) + } + } +} + // TestCanReusePaymentWithSameProvider 测试相同渠道可复用 func TestCanReusePaymentWithSameProvider(t *testing.T) { payment := model.PaymentOrder{