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 { 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) } 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", "退款失败,请稍后重试") } }