e26f995d7f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
141 lines
4.9 KiB
Go
141 lines
4.9 KiB
Go
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", "退款失败,请稍后重试")
|
|
}
|
|
}
|