feat: 对齐 Waffo 包年/永久结账与客户端席位 API
同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4ae318335c
commit
95405de119
14 files changed
+958
-155
No files matched your search
@@ -4,8 +4,11 @@ import (
|
||||
"backend/internal/dto"
|
||||
orderservice "backend/internal/service/order"
|
||||
"backend/utils"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -53,15 +56,7 @@ func (ctrl *OrderController) GetDetail(c *gin.Context) {
|
||||
|
||||
// CreateOrder 客户端下单(公开接口) // Logan.Li
|
||||
func (ctrl *OrderController) CreateOrder(c *gin.Context) {
|
||||
var req struct {
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ProductID uint `json:"product_id" binding:"required"`
|
||||
PlanID uint `json:"plan_id" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
CancelURL string `json:"cancel_url"`
|
||||
}
|
||||
var req dto.CreateOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||
return
|
||||
@@ -74,20 +69,39 @@ func (ctrl *OrderController) CreateOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp})
|
||||
}
|
||||
|
||||
// Checkout desktop-friendly checkout entry. // Logan.Li
|
||||
func (ctrl *OrderController) Checkout(c *gin.Context) {
|
||||
var req dto.CheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := ctrl.svc.Checkout(req, utils.GetClientIP(c), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 2001, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp})
|
||||
}
|
||||
|
||||
// ClaimPurchase fetch license after payment. // Logan.Li
|
||||
func (ctrl *OrderController) ClaimPurchase(c *gin.Context) {
|
||||
var req dto.ClaimPurchaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := ctrl.svc.ClaimPurchase(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 2002, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||
}
|
||||
|
||||
// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li
|
||||
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
||||
var req struct {
|
||||
EventType string `json:"event_type" binding:"required"`
|
||||
OrderNo string `json:"order_no" binding:"required"`
|
||||
GatewayOrderNo string `json:"gateway_order_no"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
Gateway string `json:"gateway" binding:"required"`
|
||||
Signature string `json:"signature"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
var req dto.PaymentWebhookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"})
|
||||
return
|
||||
@@ -99,16 +113,133 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
|
||||
}
|
||||
|
||||
// WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li
|
||||
func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"})
|
||||
return
|
||||
}
|
||||
// Optional: accept X-Waffo-Signature if present (no strict verification yet). // Logan.Li
|
||||
_ = c.GetHeader("X-Waffo-Signature")
|
||||
|
||||
var payload map[string]interface{}
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
|
||||
orderNo := firstNonEmpty(
|
||||
asString(payload["order_no"]),
|
||||
asString(payload["orderNo"]),
|
||||
asString(payload["order_id"]),
|
||||
c.Query("order_no"),
|
||||
c.Query("orderNo"),
|
||||
)
|
||||
if orderNo == "" {
|
||||
if meta, ok := payload["metadata"].(map[string]interface{}); ok {
|
||||
orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"]))
|
||||
}
|
||||
}
|
||||
if orderNo == "" {
|
||||
if data, ok := payload["data"].(map[string]interface{}); ok {
|
||||
orderNo = firstNonEmpty(asString(data["order_no"]), asString(data["orderNo"]))
|
||||
if orderNo == "" {
|
||||
if meta, ok := data["metadata"].(map[string]interface{}); ok {
|
||||
orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if orderNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing order_no"})
|
||||
return
|
||||
}
|
||||
|
||||
eventType := firstNonEmpty(
|
||||
asString(payload["event_type"]),
|
||||
asString(payload["eventType"]),
|
||||
asString(payload["type"]),
|
||||
asString(payload["event"]),
|
||||
"payment",
|
||||
)
|
||||
statusRaw := strings.ToLower(firstNonEmpty(
|
||||
asString(payload["status"]),
|
||||
asString(payload["payment_status"]),
|
||||
asString(payload["paymentStatus"]),
|
||||
))
|
||||
gatewayOrderNo := firstNonEmpty(
|
||||
asString(payload["gateway_order_no"]),
|
||||
asString(payload["gatewayOrderNo"]),
|
||||
asString(payload["transaction_id"]),
|
||||
asString(payload["transactionId"]),
|
||||
asString(payload["id"]),
|
||||
)
|
||||
|
||||
mappedStatus := "paid"
|
||||
switch {
|
||||
case strings.Contains(statusRaw, "fail"), strings.Contains(statusRaw, "cancel"),
|
||||
strings.Contains(eventType, "fail"), strings.Contains(eventType, "cancel"):
|
||||
mappedStatus = "failed"
|
||||
case strings.Contains(statusRaw, "refund"), strings.Contains(eventType, "refund"):
|
||||
mappedStatus = "refunded"
|
||||
case statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") ||
|
||||
strings.Contains(statusRaw, "success") || strings.Contains(eventType, "complete") ||
|
||||
strings.Contains(eventType, "paid") || strings.Contains(eventType, "success"):
|
||||
mappedStatus = "paid"
|
||||
default:
|
||||
// Treat unknown completed-like events as paid for now. // Logan.Li
|
||||
mappedStatus = "paid"
|
||||
}
|
||||
|
||||
req := dto.PaymentWebhookRequest{
|
||||
EventType: eventType,
|
||||
OrderNo: orderNo,
|
||||
GatewayOrderNo: gatewayOrderNo,
|
||||
Status: mappedStatus,
|
||||
Gateway: "waffo",
|
||||
Payload: string(body),
|
||||
Signature: c.GetHeader("X-Waffo-Signature"),
|
||||
}
|
||||
if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
|
||||
}
|
||||
|
||||
func asString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ManualGrant 手动发放 License(管理后台) // Logan.Li
|
||||
func (ctrl *OrderController) ManualGrant(c *gin.Context) {
|
||||
var req struct {
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ProductID uint `json:"product_id" binding:"required"`
|
||||
PlanID uint `json:"plan_id" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
var req dto.ManualGrantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||
return
|
||||
@@ -125,10 +256,7 @@ func (ctrl *OrderController) ManualGrant(c *gin.Context) {
|
||||
|
||||
// CheckDuplicatePurchase 防重复购买检测(公开接口) // Logan.Li
|
||||
func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) {
|
||||
var req struct {
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
ProductID uint `json:"product_id" binding:"required"`
|
||||
}
|
||||
var req dto.DuplicatePurchaseCheckRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
@@ -151,10 +279,7 @@ func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) {
|
||||
|
||||
// CheckPurchaseStatus 查询购买状态(公开接口) // Logan.Li
|
||||
func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) {
|
||||
var req struct {
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
ProductID uint `json:"product_id" binding:"required"`
|
||||
}
|
||||
var req dto.CheckPurchaseStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user