diff --git a/internal/handler/refund.go b/internal/handler/refund.go new file mode 100644 index 0000000..c742d92 --- /dev/null +++ b/internal/handler/refund.go @@ -0,0 +1,135 @@ +package handler + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +type refundRequest struct { + OutTradeNo string `json:"out_trade_no"` + AmountMinor int64 `json:"amount_minor"` + Reason string `json:"reason,omitempty"` + BizSystem string `json:"biz_system"` +} + +// CreateRefund POST /api/v2/refunds —— 业务发起退款(HMAC 验签)。 +func (h *GatewayHandler) CreateRefund(c *gin.Context) { + raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)) + if err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败") + return + } + var req refundRequest + if err := json.Unmarshal(raw, &req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误") + return + } + if req.OutTradeNo == "" || req.AmountMinor <= 0 || req.BizSystem == "" { + util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 out_trade_no / amount_minor / biz_system") + return + } + if err := verifyBizSign(c, req.BizSystem, raw); err != nil { + util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + res, err := h.g.Refund(c.Request.Context(), gateway.RefundInput{ + OutTradeNo: req.OutTradeNo, AmountMinor: req.AmountMinor, Reason: req.Reason, + BizSystem: req.BizSystem, InitiatedBy: "business", + }) + if err != nil { + h.writeRefundErr(c, err) + return + } + util.RespondSuccess(c, res) +} + +// GetRefund GET /api/v2/refunds/:refund_id +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", "退款单不存在") + return + } + util.RespondSuccess(c, v) +} + +func (h *GatewayHandler) adminAuthorized(c *gin.Context) bool { + tok := os.Getenv("PAY_ADMIN_TOKEN") + if tok == "" { + return false // 未配置管理密钥 → 端点禁用 + } + return subtle.ConstantTimeCompare([]byte(c.GetHeader("X-Pay-Admin")), []byte(tok)) == 1 +} + +// ListManualRefunds GET /api/v2/admin/manual-refunds —— 捞 crypto 人工待办。 +func (h *GatewayHandler) ListManualRefunds(c *gin.Context) { + if !h.adminAuthorized(c) { + util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限") + return + } + rs, err := h.g.ListManualPendingRefunds(50) + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "list_failed", "查询失败") + return + } + util.RespondSuccess(c, gin.H{"refunds": rs}) +} + +type completeRefundRequest struct { + ProviderRefundRef string `json:"provider_refund_ref"` +} + +// CompleteRefund POST /api/v2/admin/refunds/:refund_id/complete —— 运营回填人工退款完成。 +func (h *GatewayHandler) CompleteRefund(c *gin.Context) { + if !h.adminAuthorized(c) { + util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限") + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes) + var req completeRefundRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误") + return + } + res, err := h.g.CompleteManualRefund(c.Request.Context(), c.Param("refund_id"), req.ProviderRefundRef) + if err != nil { + switch { + case errors.Is(err, store.ErrRefundNotFound): + util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在") + case errors.Is(err, gateway.ErrRefundNotManual): + util.RespondError(c, http.StatusConflict, "not_manual", "该退款单非人工待办态") + default: + log.Printf("[v2 refund] 人工完成失败 %s: %v", c.Param("refund_id"), err) + util.RespondError(c, http.StatusInternalServerError, "complete_failed", "完成失败") + } + return + } + util.RespondSuccess(c, res) +} + +func (h *GatewayHandler) writeRefundErr(c *gin.Context, err error) { + switch { + case errors.Is(err, store.ErrOrderNotFound): + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在") + case errors.Is(err, gateway.ErrOrderNotRefundable): + util.RespondError(c, http.StatusConflict, "order_not_refundable", "订单不可退款(未支付/已全额退款/非本业务)") + case errors.Is(err, gateway.ErrRefundAmountInvalid): + util.RespondError(c, http.StatusUnprocessableEntity, "refund_amount_invalid", "退款金额无效或超过可退余额") + case errors.Is(err, store.ErrAttemptNotFound): + util.RespondError(c, http.StatusConflict, "no_paid_attempt", "订单无已支付记录") + default: + log.Printf("[v2 refund] 退款失败: %v", err) + util.RespondError(c, http.StatusInternalServerError, "refund_failed", "退款失败,请稍后重试") + } +} diff --git a/internal/handler/refund_test.go b/internal/handler/refund_test.go new file mode 100644 index 0000000..fa1657a --- /dev/null +++ b/internal/handler/refund_test.go @@ -0,0 +1,131 @@ +package handler_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/router" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/util" +) + +const refundSecret = "s3cr3t-pangolin" + +func jsonBody(b []byte) *bytes.Reader { return bytes.NewReader(b) } +func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } +func serve(t *testing.T, r *gin.Engine, req *http.Request) *http.Response { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w.Result() +} + +func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *fake.Provider) { + t.Helper() + gin.SetMode(gin.TestMode) + config.C = config.Config{Biz: map[string]config.BizSystemConfig{ + "pangolin": {Secret: refundSecret, CallbackURL: "http://x"}, + }} + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}}) + g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + return r, g, orders, fp +} + +// 用 pangolin secret 给退款请求签名(与 verifyBizSign 一致)。 +func signedRefundReq(t *testing.T, r *gin.Engine, body map[string]any) *http.Response { + t.Helper() + raw, _ := json.Marshal(body) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := uuid.NewString() + sign := util.HMACSign(refundSecret, "pangolin", ts, nonce, string(raw)) + req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Pay-System", "pangolin") + req.Header.Set("X-Pay-Timestamp", ts) + req.Header.Set("X-Pay-Nonce", nonce) + req.Header.Set("X-Pay-Sign", sign) + return serve(t, r, req) +} + +func TestRefundEndpointE2E(t *testing.T) { + r, g, orders, fp := buildRefundEngine(t) + fp.EnableRefund("fake-ref", provider.PaidSucceeded, nil) + + // 下单 + 付 + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + _, _ = g.SyncPendingAttempts(context.Background(), 10) + + // 签名退款 → 200 succeeded + resp := signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000, "biz_system": "pangolin", "reason": "test"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("refund status = %d want 200", resp.StatusCode) + } + if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 { + t.Fatalf("order = %s want refunded", o.Status) + } + + // 未签名 → 401 + req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(mustJSON(map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 1, "biz_system": "pangolin"}))) + req.Header.Set("Content-Type", "application/json") + if resp := serve(t, r, req); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unsigned status = %d want 401", resp.StatusCode) + } +} + +func TestManualRefundAdminFlow(t *testing.T) { + t.Setenv("PAY_ADMIN_TOKEN", "adm-tok") + r, g, orders, fp := buildRefundEngine(t) + // fp 不 EnableRefund → manual_pending + res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"}) + atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) + fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) + _, _ = g.SyncPendingAttempts(context.Background(), 10) + mr, _ := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"}) + + // admin 无 token → 403 + req, _ := http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil) + if resp := serve(t, r, req); resp.StatusCode != http.StatusForbidden { + t.Fatalf("no-token status = %d want 403", resp.StatusCode) + } + // admin 带 token → 200 且列出待办 + req, _ = http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil) + req.Header.Set("X-Pay-Admin", "adm-tok") + if resp := serve(t, r, req); resp.StatusCode != http.StatusOK { + t.Fatalf("admin list status = %d want 200", resp.StatusCode) + } + // complete → order refunded + 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("complete status = %d want 200", resp.StatusCode) + } + if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 { + t.Fatalf("order = %s want refunded", o.Status) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index ad53b4d..974399c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -49,5 +49,13 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) { v2.POST("/orders/:order_no/retry", h.Retry) v2.POST("/orders/:order_no/cancel", h.Cancel) v2.POST("/callback/:method", h.Callback) + v2.POST("/refunds", h.CreateRefund) + v2.GET("/refunds/:refund_id", h.GetRefund) + } + // 人工退款闭环:独立 admin 组(env PAY_ADMIN_TOKEN 保护),路径与 v2/refunds 分离避免路由冲突。 + admin := r.Group("/api/v2/admin") + { + admin.GET("/manual-refunds", h.ListManualRefunds) + admin.POST("/refunds/:refund_id/complete", h.CompleteRefund) } }