feat: 添加 HashPay 支付渠道支持
后端: - payment.go: 添加 HashPay 支付处理器 - RSA-SHA256 签名 - RSA-OAEP-256+A256GCM 回调解密 - 创建订单和回调处理 - routes.go: 添加 HashPay 回调路由 前端: - PaymentChannels.vue: 添加 HashPay 类型选项 - 商户ID配置 - RSA私钥配置 - API地址配置 参考 bepusdt 的交互方式,支持多种加密货币支付
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -146,6 +146,22 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'hashpay'">
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="channelConfig.hashpay_base_url" placeholder="如:https://pay.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商户ID">
|
||||
<el-input v-model="channelConfig.hashpay_merchant_id" placeholder="HashPay 商户ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商户私钥">
|
||||
<el-input v-model="channelConfig.hashpay_private_key" type="textarea" :rows="6" placeholder="RSA 私钥 (PEM格式)" />
|
||||
</el-form-item>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
<p style="margin: 0; font-size: 12px;">HashPay 支持多种加密货币支付,包括 USDT/TRC20、USDT/ERC20、SOL 等。</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 12px;">请在 HashPay 后台创建商户并获取商户ID和私钥。</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'bank'">
|
||||
<el-form-item label="银行名称">
|
||||
<el-input v-model="channelConfig.bank_name" placeholder="如:中国工商银行" />
|
||||
@@ -211,6 +227,7 @@ const channelTypes = [
|
||||
{ value: 'alipay', label: '支付宝', icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="#1677ff"><rect rx="4" width="24" height="24" fill="#1677ff"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">支</text></svg>' },
|
||||
{ value: 'wechat', label: '微信支付', icon: '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#07c160"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">微</text></svg>' },
|
||||
{ value: 'bepusdt', label: 'BepUsdt', icon: '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#26a17b"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">₮</text></svg>' },
|
||||
{ value: 'hashpay', label: 'HashPay', icon: '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#f7931a"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">₿</text></svg>' },
|
||||
{ value: 'bank', label: '银行卡', icon: '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#e6a23c"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">卡</text></svg>' },
|
||||
{ value: 'other', label: '其他', icon: '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="17" text-anchor="middle" fill="#fff" font-size="14" font-weight="bold">…</text></svg>' },
|
||||
]
|
||||
@@ -244,6 +261,7 @@ function onTypeChange(type: string) {
|
||||
alipay: 'alipay',
|
||||
wechat: 'wechat',
|
||||
bepusdt: 'usdt',
|
||||
hashpay: 'crypto',
|
||||
bank: 'bank',
|
||||
}
|
||||
form.icon = iconMap[type] || ''
|
||||
|
||||
Reference in New Issue
Block a user