feat(v2): 退款 HTTP 端点(POST /refunds 验签发起 + 状态查询 + admin 人工闭环)
This commit is contained in:
@@ -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", "退款失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user