diff --git a/backend/internal/api/handlers/payment.go b/backend/internal/api/handlers/payment.go index e80a56c..8bfd931 100644 --- a/backend/internal/api/handlers/payment.go +++ b/backend/internal/api/handlers/payment.go @@ -1,9 +1,18 @@ package handlers import ( + "crypto" + "crypto/aes" + "crypto/cipher" "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" "log" @@ -63,8 +72,21 @@ func (h *PaymentHandler) CreatePayment(c *gin.Context) { return } + // 根据订单支付方式获取对应的支付通道 + paymentType := order.PaymentMethod + if paymentType == "" { + paymentType = "bepusdt" // 默认使用 bepusdt + } + + // 如果是 hashpay,调用 HashPay 支付 + if paymentType == "hashpay" { + h.CreateHashPayPayment(c) + return + } + + // BepUsdt 支付流程 var channels []models.PaymentChannel - utils.DB.Where("is_enabled = ? AND type = ?", true, "bepusdt").Order("sort_order ASC").Find(&channels) + utils.DB.Where("is_enabled = ? AND type = ?", true, paymentType).Order("sort_order ASC").Find(&channels) if len(channels) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No available payment channel"}) return @@ -321,7 +343,7 @@ func parseChannelConfig(configStr string) map[string]string { if err2 := json.Unmarshal([]byte(configStr), &rawStr); err2 == nil { return rawStr } - log.Printf("[BepUsdt] failed to parse channel config: %v", err) + log.Printf("[Payment] failed to parse channel config: %v", err) return result } for k, v := range raw { @@ -345,3 +367,354 @@ func parseChannelConfig(configStr string) map[string]string { } return result } + +// ===================== HashPay 支付 ===================== + +// HashPay 创建订单请求 +type HashPayCreateRequest struct { + MerchantNo string `json:"merchantNo"` + Amount float64 `json:"amount"` + Currency string `json:"currency,omitempty"` + Description string `json:"description,omitempty"` + ReturnURL string `json:"return_url,omitempty"` +} + +// HashPay 创建订单响应 +type HashPayCreateResponse struct { + CheckoutURL string `json:"checkoutUrl"` + Order struct { + ID string `json:"id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + ExpiresAt int64 `json:"expiresAt"` + Status string `json:"status"` + } `json:"order"` + Reused bool `json:"reused"` +} + +// CreateHashPayPayment 创建 HashPay 支付订单 +func (h *PaymentHandler) CreateHashPayPayment(c *gin.Context) { + userID := c.GetUint("user_id") + orderID, _ := strconv.Atoi(c.Param("id")) + + var order models.Order + if err := utils.DB.Preload("OrderItems").Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"}) + return + } + + if order.Status != models.OrderStatusPendingPayment { + c.JSON(http.StatusBadRequest, gin.H{"error": "Order is not pending payment"}) + return + } + + if order.PaymentExpiresAt != nil && time.Now().After(*order.PaymentExpiresAt) { + tx := utils.DB.Begin() + tx.Model(&order).Update("status", models.OrderStatusCancelled) + for _, item := range order.OrderItems { + var inventory models.Inventory + if err := tx.Where("product_id = ?", item.ProductID).First(&inventory).Error; err == nil { + tx.Model(&inventory).UpdateColumn("quantity", inventory.Quantity+item.Quantity) + } + } + tx.Commit() + c.JSON(http.StatusBadRequest, gin.H{"error": "支付已超时,订单已取消"}) + return + } + + var channels []models.PaymentChannel + utils.DB.Where("is_enabled = ? AND type = ?", true, "hashpay").Order("sort_order ASC").Find(&channels) + if len(channels) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No available HashPay payment channel"}) + return + } + + channel := channels[0] + config := parseChannelConfig(channel.Config) + + baseURL := config["hashpay_base_url"] + merchantID := config["hashpay_merchant_id"] + privateKeyPEM := config["hashpay_private_key"] + + if baseURL == "" || merchantID == "" || privateKeyPEM == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "HashPay channel not configured properly"}) + return + } + + // 解析私钥 + privateKey, err := parseRSAPrivateKey(privateKeyPEM) + if err != nil { + log.Printf("[HashPay] failed to parse private key: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid private key configuration"}) + return + } + + scheme := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := c.Request.Host + apiBaseURL := fmt.Sprintf("%s://%s", scheme, host) + + merchantNo := fmt.Sprintf("ORDER-%d", order.ID) + returnURL := fmt.Sprintf("%s/orders/%d", apiBaseURL, order.ID) + + reqBody := HashPayCreateRequest{ + MerchantNo: merchantNo, + Amount: order.TotalAmount, + Currency: "USD", + Description: fmt.Sprintf("Order #%d", order.ID), + ReturnURL: returnURL, + } + + bodyBytes, _ := json.Marshal(reqBody) + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + + // 生成签名 + signature, err := signHashPay("POST", "/api/merchant/new", timestamp, string(bodyBytes), privateKey) + if err != nil { + log.Printf("[HashPay] failed to sign request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sign request"}) + return + } + + // 发送请求 + req, err := http.NewRequest("POST", baseURL+"/api/merchant/new", strings.NewReader(string(bodyBytes))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Merchant-Id", merchantID) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Signature", signature) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("[HashPay] request failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect to HashPay"}) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + log.Printf("[HashPay] response: %s", string(respBody)) + + var result HashPayCreateResponse + if err := json.Unmarshal(respBody, &result); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid response from HashPay"}) + return + } + + if result.CheckoutURL == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "No checkout URL returned"}) + return + } + + // 更新订单支付方式 + utils.DB.Model(&order).Updates(map[string]interface{}{ + "payment_method": "hashpay", + }) + + // 计算过期时间 + var expiresAt *time.Time + if result.Order.ExpiresAt > 0 { + t := time.Unix(result.Order.ExpiresAt, 0) + expiresAt = &t + } + + paymentInfo := map[string]interface{}{ + "order_id": order.ID, + "checkout_url": result.CheckoutURL, + "hashpay_id": result.Order.ID, + "amount": result.Order.Amount, + "currency": result.Order.Currency, + "expires_at": expiresAt, + "channel_id": channel.ID, + } + + c.JSON(http.StatusOK, gin.H{"data": paymentInfo}) +} + +// HashPayNotify 处理 HashPay 回调通知 +func (h *PaymentHandler) HashPayNotify(c *gin.Context) { + body, _ := io.ReadAll(c.Request.Body) + + log.Printf("[HashPay] notify body: %s", string(body)) + + var envelope struct { + Alg string `json:"alg"` + Key string `json:"key"` + IV string `json:"iv"` + Data string `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + c.String(http.StatusOK, "ok") + return + } + + // 获取商户私钥进行解密 + var channels []models.PaymentChannel + utils.DB.Where("type = ?", "hashpay").Find(&channels) + if len(channels) == 0 { + c.String(http.StatusOK, "ok") + return + } + + channel := channels[0] + config := parseChannelConfig(channel.Config) + privateKeyPEM := config["hashpay_private_key"] + + privateKey, err := parseRSAPrivateKey(privateKeyPEM) + if err != nil { + log.Printf("[HashPay] failed to parse private key for decryption: %v", err) + c.String(http.StatusOK, "ok") + return + } + + // 解密回调数据 + payload, err := decryptHashPayNotify(envelope.Key, envelope.IV, envelope.Data, privateKey) + if err != nil { + log.Printf("[HashPay] failed to decrypt notify: %v", err) + c.String(http.StatusOK, "ok") + return + } + + log.Printf("[HashPay] decrypted payload: %+v", payload) + + // 解析订单号 + merchantNo := payload.MerchantNo + if merchantNo == "" { + c.String(http.StatusOK, "ok") + return + } + + // 从 merchantNo 中提取订单ID (格式: ORDER-123) + orderIDStr := strings.TrimPrefix(merchantNo, "ORDER-") + orderID, err := strconv.Atoi(orderIDStr) + if err != nil { + c.String(http.StatusOK, "ok") + return + } + + var order models.Order + if err := utils.DB.First(&order, orderID).Error; err != nil { + c.String(http.StatusOK, "ok") + return + } + + // 更新订单状态 + if payload.Status == "paid" { + utils.DB.Model(&order).Update("status", models.OrderStatusPendingConfirm) + } else if payload.Status == "expired" || payload.Status == "cancelled" { + utils.DB.Model(&order).Update("status", models.OrderStatusCancelled) + } + + c.String(http.StatusOK, "ok") +} + +// HashPayNotifyPayload 回调解密后的数据结构 +type HashPayNotifyPayload struct { + Timestamp int64 `json:"timestamp"` + MerchantNo string `json:"merchantNo"` + OrderID string `json:"orderId"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` +} + +// parseRSAPrivateKey 解析 RSA 私钥 +func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block") + } + + // 尝试 PKCS1 + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + // 尝试 PKCS8 + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an RSA private key") + } + return rsaKey, nil +} + +// signHashPay 生成 HashPay 请求签名 +func signHashPay(method, path, timestamp, body string, privateKey *rsa.PrivateKey) (string, error) { + // 签名原文: method + "\n" + path + "\n" + timestamp + "\n" + body + message := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, timestamp, body) + + hashed := sha256.Sum256([]byte(message)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// decryptHashPayNotify 解密 HashPay 回调通知 +func decryptHashPayNotify(encryptedKey, iv, encryptedData string, privateKey *rsa.PrivateKey) (*HashPayNotifyPayload, error) { + // 解码 Base64 + keyBytes, err := base64.StdEncoding.DecodeString(encryptedKey) + if err != nil { + return nil, fmt.Errorf("failed to decode key: %v", err) + } + + ivBytes, err := base64.StdEncoding.DecodeString(iv) + if err != nil { + return nil, fmt.Errorf("failed to decode iv: %v", err) + } + + dataBytes, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("failed to decode data: %v", err) + } + + // 使用 RSA-OAEP 解密 AES 密钥 + aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, keyBytes, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt AES key: %v", err) + } + + // 使用 AES-256-GCM 解密数据 + // Go 标准库的 AES-GCM 实现 + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %v", err) + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %v", err) + } + + // GCM 的 nonce 就是 IV,密文最后是 tag + // dataBytes = ciphertext + tag + tagSize := aesgcm.Overhead() + if len(dataBytes) < tagSize { + return nil, fmt.Errorf("ciphertext too short") + } + + plaintext, err := aesgcm.Open(nil, ivBytes, dataBytes, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt data: %v", err) + } + + var payload HashPayNotifyPayload + if err := json.Unmarshal(plaintext, &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload: %v", err) + } + + return &payload, nil +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index a33b74b..5a640a1 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -72,6 +72,7 @@ func SetupRoutes(r *gin.Engine) { api.GET("/settings/public", systemHandler.GetPublicSettings) api.GET("/payment-channels", paymentChannelHandler.PublicList) api.POST("/payment/bepusdt/notify", paymentHandler.BepUsdtNotify) + api.POST("/payment/hashpay/notify", paymentHandler.HashPayNotify) api.POST("/shipping/calculate", shippingTemplateHandler.CalculateForUser) authed := api.Group("") diff --git a/frontend/src/views/admin/PaymentChannels.vue b/frontend/src/views/admin/PaymentChannels.vue index 9078fdf..4708cf0 100644 --- a/frontend/src/views/admin/PaymentChannels.vue +++ b/frontend/src/views/admin/PaymentChannels.vue @@ -146,6 +146,22 @@ + +