Files

376 lines
9.7 KiB
Go

package handler
import (
"errors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"yingce/backend/internal/service"
"github.com/gin-gonic/gin"
)
const paymentNotificationMaxBytes = 1 << 20
var paymentOrderIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/payments/providers", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
providers, err := svc.PaymentProviders(user)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"providers": providers})
})
r.GET("/payments/products", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
products, err := svc.TopupProducts(user)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"products": products})
})
r.POST("/payments/orders", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "payment-order:"+user.ID, 20, time.Hour) {
return
}
var request service.CreatePaymentOrderRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
order, err := svc.CreatePaymentOrder(c.Request.Context(), user, request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
r.GET("/payments/orders/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
order, err := svc.PaymentOrder(user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
r.POST("/payments/orders/:id/query", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "payment-query:"+user.ID+":"+c.Param("id"), 30, time.Minute) {
return
}
order, err := svc.QueryPaymentOrder(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
r.POST("/payments/orders/:id/close", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "payment-close:"+user.ID+":"+c.Param("id"), 10, time.Minute) {
return
}
order, err := svc.ClosePaymentOrder(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
r.GET("/payments/orders/:id/checkout", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
target, err := svc.PaymentCheckout(user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
c.Redirect(http.StatusFound, target)
})
r.POST("/payments/orders/:id/checkout/refresh", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "payment-checkout-refresh:"+user.ID+":"+c.Param("id"), 5, time.Hour) {
return
}
order, err := svc.RefreshPaymentCheckout(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
// Provider callbacks are intentionally unauthenticated at the application
// layer. Authenticity is established by the pinned provider config and raw
// request signature before any durable event is accepted.
notifyHandler := func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, paymentNotificationMaxBytes)
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
writePaymentNotificationFailure(c, svc, c.Param("providerId"), http.StatusBadRequest)
return
}
if len(rawBody) == 0 && c.Request.URL.RawQuery != "" {
rawBody = []byte(c.Request.URL.RawQuery)
}
err = svc.AcceptPaymentNotification(c.Request.Context(), c.Param("providerId"), c.Param("configId"), c.Request.Header.Clone(), rawBody)
if err != nil {
status := http.StatusInternalServerError
var appErr *service.AppError
if errors.As(err, &appErr) && appErr.Status < http.StatusInternalServerError {
status = http.StatusBadRequest
}
writePaymentNotificationFailure(c, svc, c.Param("providerId"), status)
return
}
status, contentType, body := svc.PaymentNotificationResponse(c.Param("providerId"), true)
if body != "" {
c.Data(status, contentType, []byte(body))
return
}
c.Status(status)
}
r.POST("/payments/notify/:providerId/:configId", notifyHandler)
r.GET("/payments/notify/:providerId/:configId", notifyHandler)
r.GET("/payments/return/:providerId", func(c *gin.Context) {
orderID := strings.ToLower(strings.TrimSpace(c.Query("orderId")))
if !paymentOrderIDPattern.MatchString(orderID) {
c.Redirect(http.StatusFound, "/wallet?payment=invalid")
return
}
c.Redirect(http.StatusFound, "/wallet?paymentOrder="+url.QueryEscape(orderID))
})
admin := r.Group("/admin/payments")
registerPaymentExportRoutes(admin, svc)
admin.GET("/providers", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
providers, err := svc.AdminPaymentProviders(user)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"providers": providers})
})
admin.PUT("/providers/:id/config", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var request service.UpdatePaymentProviderConfigRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
provider, err := svc.UpdatePaymentProviderConfig(user, c.Param("id"), request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"provider": provider})
})
admin.GET("/products", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
products, err := svc.AdminTopupProducts(user)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"products": products})
})
admin.POST("/products", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var request service.TopupProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
product, err := svc.CreateTopupProduct(user, request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"product": product})
})
admin.PUT("/products/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var request service.TopupProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
product, err := svc.UpdateTopupProduct(user, c.Param("id"), request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"product": product})
})
admin.GET("/orders", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
page, limit, err := parsePaginationQuery(c, 30)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.AdminPaymentOrderPage(user, paymentOrderQuery(c), page, limit)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
admin.POST("/orders/:id/query", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
order, err := svc.AdminQueryPaymentOrder(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
admin.POST("/orders/:id/close", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
order, err := svc.AdminClosePaymentOrder(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"order": order})
})
admin.POST("/reconciliations", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var request service.RunPaymentReconciliationRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
run, err := svc.RunPaymentReconciliation(c.Request.Context(), user, request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"run": run})
})
admin.GET("/reconciliations", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
page, limit, err := parsePaginationQuery(c, 30)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.AdminPaymentReconciliationPage(user, paymentReconciliationQuery(c), page, limit)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
admin.GET("/reconciliations/:id/items", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
page, limit, err := parsePaginationQuery(c, 50)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.AdminPaymentReconciliationItems(user, c.Param("id"), c.Query("result"), page, limit)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
}
func writePaymentNotificationFailure(c *gin.Context, svc *service.Service, providerID string, status int) {
responseStatus, contentType, body := svc.PaymentNotificationFailureResponse(providerID, status)
if body != "" {
c.Data(responseStatus, contentType, []byte(body))
return
}
c.JSON(responseStatus, gin.H{"code": "FAIL", "message": http.StatusText(responseStatus)})
}