diff --git a/controller/topup.go b/controller/topup.go index e7a392a4d31d..8f11593e9b40 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -78,10 +78,32 @@ func GetTopUpInfo(c *gin.Context) { } } + enableJeepay := isJeepayConfigured() + if enableJeepay { + hasJeepay := false + for _, method := range payMethods { + if method["type"] == PaymentMethodJeepay { + hasJeepay = true + break + } + } + if !hasJeepay { + payMethods = append(payMethods, map[string]string{ + "name": "Jeepay", + "type": PaymentMethodJeepay, + "color": "rgba(var(--semi-green-5), 1)", + "min_topup": strconv.Itoa(setting.JeepayMinTopUp), + }) + } + } + data := gin.H{ "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", + "enable_jeepay_topup": enableJeepay, + "jeepay_way_code": getJeepayWayCode(), + "jeepay_order_timeout_minutes": setting.JeepayOrderTimeoutMinutes, "enable_waffo_topup": enableWaffo, "waffo_pay_methods": func() interface{} { if enableWaffo { @@ -92,6 +114,7 @@ func GetTopUpInfo(c *gin.Context) { "creem_products": setting.CreemProducts, "pay_methods": payMethods, "min_topup": operation_setting.MinTopUp, + "jeepay_min_topup": setting.JeepayMinTopUp, "stripe_min_topup": setting.StripeMinTopUp, "waffo_min_topup": setting.WaffoMinTopUp, "amount_options": operation_setting.GetPaymentSetting().AmountOptions, @@ -463,4 +486,3 @@ func AdminCompleteTopUp(c *gin.Context) { } common.ApiSuccess(c, nil) } - diff --git a/controller/topup_jeepay.go b/controller/topup_jeepay.go new file mode 100644 index 000000000000..63ebef29172b --- /dev/null +++ b/controller/topup_jeepay.go @@ -0,0 +1,608 @@ +package controller + +import ( + "bytes" + "context" + "crypto/md5" + "fmt" + "io" + "log" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodJeepay = "jeepay" + + jeepaySignTypeMD5 = "MD5" + jeepayVersion = "1.0" + jeepayStateSuccess = "2" + jeepayStateFailed = "3" + jeepayUnifiedOrderURI = "/api/pay/unifiedOrder" +) + +type JeepayPayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + WayCode string `json:"way_code,omitempty"` +} + +type jeepayUnifiedOrderRequest struct { + MchNo string `json:"mchNo"` + AppID string `json:"appId"` + MchOrderNo string `json:"mchOrderNo"` + WayCode string `json:"wayCode"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + ClientIP string `json:"clientIp"` + Subject string `json:"subject"` + Body string `json:"body"` + NotifyURL string `json:"notifyUrl"` + ReturnURL string `json:"returnUrl,omitempty"` + ExpiredTime int64 `json:"expiredTime,omitempty"` + ReqTime int64 `json:"reqTime"` + Version string `json:"version"` + Sign string `json:"sign"` + SignType string `json:"signType"` +} + +type jeepayResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Sign string `json:"sign"` + Data map[string]interface{} `json:"data"` +} + +func getJeepayMinTopup() int64 { + if setting.JeepayMinTopUp <= 0 { + return 1 + } + return int64(setting.JeepayMinTopUp) +} + +func getJeepayOrderTimeoutMinutes() int64 { + if setting.JeepayOrderTimeoutMinutes <= 0 { + return 5 + } + return int64(setting.JeepayOrderTimeoutMinutes) +} + +func getJeepayExpiredTime() int64 { + return getJeepayOrderTimeoutMinutes() * 60 +} + +func isJeepayConfigured() bool { + return setting.JeepayBaseURL != "" && + setting.JeepayMchNo != "" && + setting.JeepayAppID != "" && + setting.JeepayAPIKey != "" +} + +func buildJeepaySign(params map[string]interface{}, apiKey string) string { + if apiKey == "" { + return "" + } + + keys := make([]string, 0, len(params)) + for key, value := range params { + if value == nil || key == "sign" { + continue + } + strValue := jeepayValueToString(value) + if strValue == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + var builder strings.Builder + for index, key := range keys { + if index > 0 { + builder.WriteByte('&') + } + builder.WriteString(key) + builder.WriteByte('=') + builder.WriteString(jeepayValueToString(params[key])) + } + if builder.Len() > 0 { + builder.WriteByte('&') + } + builder.WriteString("key=") + builder.WriteString(apiKey) + + sum := md5.Sum([]byte(builder.String())) + return strings.ToUpper(fmt.Sprintf("%x", sum)) +} + +func jeepayValueToString(value interface{}) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + case float64: + return strconv.FormatInt(int64(typed), 10) + case float32: + return strconv.FormatInt(int64(typed), 10) + case bool: + if typed { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", typed) + } +} + +func RequestJeepayPay(c *gin.Context) { + var req JeepayPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.PaymentMethod != PaymentMethodJeepay { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + if !isJeepayConfigured() { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置 Jeepay 支付信息"}) + return + } + if req.Amount < getJeepayMinTopup() { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getJeepayMinTopup())}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getPayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + tradeNo := fmt.Sprintf("JEEPAY-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6)) + callbackAddr := service.GetCallbackAddress() + notifyURL := callbackAddr + "/api/jeepay/notify" + if setting.JeepayNotifyURL != "" { + notifyURL = setting.JeepayNotifyURL + } + returnURL := system_setting.ServerAddress + "/console/topup?show_history=true" + if setting.JeepayReturnURL != "" { + returnURL = setting.JeepayReturnURL + } + + reqTime := time.Now().UnixMilli() + amountFen := int64(payMoney*100 + 0.5) + if amountFen <= 0 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + amount := req.Amount + if operationQuotaDisplayIsTokens() { + amount = normalizeTokenDisplayAmount(req.Amount) + } + + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: PaymentMethodJeepay, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + orderReq := jeepayUnifiedOrderRequest{ + MchNo: setting.JeepayMchNo, + AppID: setting.JeepayAppID, + MchOrderNo: tradeNo, + WayCode: resolveJeepayWayCode(req.WayCode), + Amount: amountFen, + Currency: "cny", + ClientIP: c.ClientIP(), + Subject: fmt.Sprintf("new-api top-up %d", req.Amount), + Body: fmt.Sprintf("Top-up %d", req.Amount), + NotifyURL: notifyURL, + ReturnURL: returnURL, + ExpiredTime: getJeepayExpiredTime(), + ReqTime: reqTime, + Version: jeepayVersion, + SignType: jeepaySignTypeMD5, + } + signSource := map[string]interface{}{ + "mchNo": orderReq.MchNo, + "appId": orderReq.AppID, + "mchOrderNo": orderReq.MchOrderNo, + "wayCode": orderReq.WayCode, + "amount": orderReq.Amount, + "currency": orderReq.Currency, + "clientIp": orderReq.ClientIP, + "subject": orderReq.Subject, + "body": orderReq.Body, + "notifyUrl": orderReq.NotifyURL, + "returnUrl": orderReq.ReturnURL, + "expiredTime": orderReq.ExpiredTime, + "reqTime": orderReq.ReqTime, + "version": orderReq.Version, + "signType": orderReq.SignType, + } + orderReq.Sign = buildJeepaySign(signSource, setting.JeepayAPIKey) + + paymentURL, err := createJeepayOrder(c.Request.Context(), &orderReq) + if err != nil { + log.Printf("Jeepay 下单失败 - 订单号: %s, wayCode: %s, amountFen: %d, expiredTime: %d, err: %v", tradeNo, orderReq.WayCode, orderReq.Amount, orderReq.ExpiredTime, err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("Jeepay下单返回:%s", err.Error())}) + return + } + + expireAt := time.Now().Add(time.Duration(orderReq.ExpiredTime) * time.Second).Unix() + responseData := gin.H{ + "payment_url": paymentURL, + "order_id": tradeNo, + "way_code": orderReq.WayCode, + "money": payMoney, + "expired_time": orderReq.ExpiredTime, + "expire_at": expireAt, + } + if isJeepayQRCodeWay(orderReq.WayCode) { + responseData["qr_code_url"] = paymentURL + } + + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": responseData, + }) +} + +func GetJeepayPayStatus(c *gin.Context) { + tradeNo := strings.TrimSpace(c.Param("tradeNo")) + if tradeNo == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "订单号不能为空"}) + return + } + + userID := c.GetInt("id") + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil || topUp.UserId != userID || topUp.PaymentMethod != PaymentMethodJeepay { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "订单不存在"}) + return + } + + status := topUp.Status + expireAt := topUp.CreateTime + getJeepayExpiredTime() + if status == common.TopUpStatusPending && expireAt > 0 && time.Now().Unix() >= expireAt { + status = "expired" + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "trade_no": tradeNo, + "status": status, + "money": topUp.Money, + "expire_at": expireAt, + }, + }) +} + +func JeepayNotify(c *gin.Context) { + payload, err := parseJeepayNotifyPayload(c) + if err != nil { + c.String(http.StatusBadRequest, "fail") + return + } + + sign := jeepayValueToString(payload["sign"]) + if sign == "" { + c.String(http.StatusBadRequest, "fail") + return + } + if buildJeepaySign(payload, setting.JeepayAPIKey) != sign { + c.String(http.StatusUnauthorized, "fail") + return + } + + tradeNo := jeepayValueToString(payload["mchOrderNo"]) + if tradeNo == "" { + c.String(http.StatusBadRequest, "fail") + return + } + + state := jeepayValueToString(payload["state"]) + + if state == jeepayStateSuccess { + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil || topUp.PaymentMethod != PaymentMethodJeepay { + c.String(http.StatusBadRequest, "fail") + return + } + notifyAmount, err := parseJeepayAmountFen(payload["amount"]) + if err != nil { + c.String(http.StatusBadRequest, "fail") + return + } + expectedAmount := int64(topUp.Money*100 + 0.5) + if notifyAmount != expectedAmount { + log.Printf("Jeepay 通知金额不一致 - tradeNo: %s, expectedFen: %d, actualFen: %d", tradeNo, expectedAmount, notifyAmount) + c.String(http.StatusBadRequest, "fail") + return + } + } + + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + switch state { + case jeepayStateSuccess: + if err := model.RechargeJeepay(tradeNo); err != nil { + c.String(http.StatusInternalServerError, "fail") + return + } + case jeepayStateFailed: + if topUp := model.GetTopUpByTradeNo(tradeNo); topUp != nil && topUp.Status == common.TopUpStatusPending { + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + } + default: + } + + c.String(http.StatusOK, "success") +} + +func parseJeepayNotifyPayload(c *gin.Context) (map[string]interface{}, error) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + return nil, err + } + + trimmedBody := strings.TrimSpace(string(bodyBytes)) + if trimmedBody != "" { + var payload map[string]interface{} + if err := common.Unmarshal([]byte(trimmedBody), &payload); err == nil && len(payload) > 0 { + return payload, nil + } + } + + if len(bodyBytes) > 0 { + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + if err := c.Request.ParseForm(); err == nil { + payload := make(map[string]interface{}) + for key, values := range c.Request.PostForm { + if len(values) > 0 { + payload[key] = values[0] + } + } + if len(payload) > 0 { + return payload, nil + } + } + } + + payload := make(map[string]interface{}) + for key, values := range c.Request.URL.Query() { + if len(values) > 0 { + payload[key] = values[0] + } + } + if len(payload) > 0 { + return payload, nil + } + + return nil, fmt.Errorf("empty notify payload") +} + +func createJeepayOrder(ctx context.Context, orderReq *jeepayUnifiedOrderRequest) (string, error) { + bodyBytes, err := common.Marshal(orderReq) + if err != nil { + return "", err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(setting.JeepayBaseURL, "/")+jeepayUnifiedOrderURI, bytes.NewReader(bodyBytes)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + response, err := client.Do(request) + if err != nil { + log.Printf("Jeepay 请求失败 - url: %s, err: %v", request.URL.String(), err) + return "", err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + log.Printf("Jeepay 下单失败响应 - status: %d, body: %s", response.StatusCode, truncateJeepayLogBody(responseBody)) + return "", fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody))) + } + if err != nil { + return "", err + } + + var jeepayResp jeepayResponse + if err := common.Unmarshal(responseBody, &jeepayResp); err != nil { + log.Printf("Jeepay 响应解析失败 - status: %d, body: %s, err: %v", response.StatusCode, truncateJeepayLogBody(responseBody), err) + return "", err + } + if jeepayResp.Code != 0 { + log.Printf("Jeepay 业务失败 - code: %d, msg: %s", jeepayResp.Code, strings.TrimSpace(jeepayResp.Msg)) + return "", fmt.Errorf("%s", jeepayResp.Msg) + } + paymentURL, err := extractJeepayPaymentURL(jeepayResp.Data) + if err != nil { + log.Printf("Jeepay 支付链接提取失败 - err: %v", err) + return "", err + } + log.Printf("Jeepay 下单成功 - mchOrderNo: %s, wayCode: %s, status: %d", orderReq.MchOrderNo, orderReq.WayCode, response.StatusCode) + return paymentURL, nil +} + +func extractJeepayPaymentURL(data map[string]interface{}) (string, error) { + if data == nil { + return "", fmt.Errorf("empty data") + } + + for _, key := range []string{"payUrl", "payData", "codeUrl", "cashierUrl"} { + value := strings.TrimSpace(jeepayValueToString(data[key])) + if value != "" && strings.HasPrefix(value, "http") { + return value, nil + } + } + + if payData, ok := data["payData"].(string); ok { + trimmed := strings.TrimSpace(payData) + if strings.HasPrefix(trimmed, "http") { + return trimmed, nil + } + if strings.HasPrefix(trimmed, "{") { + var nested map[string]interface{} + if err := common.Unmarshal([]byte(trimmed), &nested); err == nil { + for _, key := range []string{"payUrl", "cashierUrl", "codeUrl"} { + value := strings.TrimSpace(jeepayValueToString(nested[key])) + if value != "" && strings.HasPrefix(value, "http") { + return value, nil + } + } + } + } + } + + if payData, ok := data["payData"].(map[string]interface{}); ok { + for _, key := range []string{"payUrl", "cashierUrl", "codeUrl"} { + value := strings.TrimSpace(jeepayValueToString(payData[key])) + if value != "" && strings.HasPrefix(value, "http") { + return value, nil + } + } + } + + return "", fmt.Errorf("payment url not found") +} + +func truncateJeepayLogBody(body []byte) string { + const maxLen = 256 + trimmed := strings.TrimSpace(string(body)) + if len(trimmed) <= maxLen { + return trimmed + } + return trimmed[:maxLen] + "..." +} + +func parseJeepayAmountFen(value interface{}) (int64, error) { + switch typed := value.(type) { + case int: + return int64(typed), nil + case int64: + return typed, nil + case float64: + return int64(typed), nil + case float32: + return int64(typed), nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, fmt.Errorf("empty amount") + } + parsed, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, err + } + return parsed, nil + default: + trimmed := strings.TrimSpace(fmt.Sprintf("%v", typed)) + if trimmed == "" { + return 0, fmt.Errorf("empty amount") + } + parsed, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, err + } + return parsed, nil + } +} + +func getJeepayWayCode() string { + if setting.JeepayWayCode != "" { + return strings.ToUpper(setting.JeepayWayCode) + } + return "WEB_CASHIER" +} + +func resolveJeepayWayCode(requestWayCode string) string { + candidate := strings.ToUpper(strings.TrimSpace(requestWayCode)) + if candidate == "" { + candidate = getJeepayWayCode() + } + if isSupportedJeepayWayCode(candidate) { + return candidate + } + return getJeepayWayCode() +} + +func isSupportedJeepayWayCode(wayCode string) bool { + switch strings.ToUpper(strings.TrimSpace(wayCode)) { + case "WEB_CASHIER", "QR_CASHIER", "WX_NATIVE", "ALI_QR": + return true + default: + return false + } +} + +func isJeepayQRCodeWay(wayCode string) bool { + switch strings.ToUpper(strings.TrimSpace(wayCode)) { + case "QR_CASHIER", "WX_NATIVE", "ALI_QR": + return true + default: + return false + } +} + +func operationQuotaDisplayIsTokens() bool { + return operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens +} + +func normalizeTokenDisplayAmount(amount int64) int64 { + if !operationQuotaDisplayIsTokens() { + return amount + } + normalized := int64(float64(amount) / common.QuotaPerUnit) + if normalized < 1 { + return 1 + } + return normalized +} diff --git a/controller/topup_jeepay_test.go b/controller/topup_jeepay_test.go new file mode 100644 index 000000000000..2c85769006a5 --- /dev/null +++ b/controller/topup_jeepay_test.go @@ -0,0 +1,177 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/glebarez/sqlite" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupJeepayTestDB(t *testing.T) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + model.DB = db + model.LOG_DB = db + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.LogConsumeEnabled = true + common.QuotaPerUnit = 1 + + require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.Log{})) +} + +func TestJeepayBuildSign(t *testing.T) { + params := map[string]interface{}{ + "mchNo": "M123", + "appId": "A123", + "mchOrderNo": "TOPUP-001", + "amount": 1234, + "currency": "cny", + "notifyUrl": "https://example.com/api/jeepay/notify", + "reqTime": int64(1710000000000), + "version": "1.0", + "signType": "MD5", + "body": "", + } + + sign := buildJeepaySign(params, "secret-key") + + require.Equal(t, "22E54179A3B0EC004B210E2942C0FA90", sign) +} + +func TestJeepayNotify(t *testing.T) { + setupJeepayTestDB(t) + gin.SetMode(gin.TestMode) + + settingJeepayForTest() + + user := &model.User{ + Username: "alice", + Password: "password123", + Status: common.UserStatusEnabled, + Role: common.RoleCommonUser, + } + require.NoError(t, model.DB.Create(user).Error) + + topUp := &model.TopUp{ + UserId: user.Id, + Amount: 100, + Money: 100, + TradeNo: "JEEPAY-TOPUP-001", + PaymentMethod: PaymentMethodJeepay, + Status: common.TopUpStatusPending, + } + require.NoError(t, topUp.Insert()) + + notifyPayload := map[string]interface{}{ + "payOrderId": "P20260404001", + "mchNo": settingJeepayMchNoForTest, + "appId": settingJeepayAppIDForTest, + "mchOrderNo": topUp.TradeNo, + "wayCode": "WEB_CASHIER", + "ifCode": "WX_NATIVE", + "state": "2", + "amount": 10000, + "currency": "cny", + "createdAt": 1710000000000, + "successTime": 1710000001000, + "signType": "MD5", + } + notifyPayload["sign"] = buildJeepaySign(notifyPayload, settingJeepayApiKeyForTest) + + bodyBytes, err := common.Marshal(notifyPayload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/api/jeepay/notify", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + JeepayNotify(c) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "success", w.Body.String()) + + savedTopUp := model.GetTopUpByTradeNo(topUp.TradeNo) + require.NotNil(t, savedTopUp) + require.Equal(t, common.TopUpStatusSuccess, savedTopUp.Status) + + var updatedUser model.User + require.NoError(t, model.DB.First(&updatedUser, user.Id).Error) + require.Equal(t, 100, updatedUser.Quota) +} + +func TestJeepayNotifyRejectsInvalidSignature(t *testing.T) { + setupJeepayTestDB(t) + gin.SetMode(gin.TestMode) + + settingJeepayForTest() + + topUp := &model.TopUp{ + UserId: 1, + Amount: 50, + Money: 50, + TradeNo: "JEEPAY-TOPUP-INVALID", + PaymentMethod: PaymentMethodJeepay, + Status: common.TopUpStatusPending, + } + require.NoError(t, model.DB.Create(&model.User{ + Id: 1, + Username: "bob", + Password: "password123", + Status: common.UserStatusEnabled, + Role: common.RoleCommonUser, + }).Error) + require.NoError(t, topUp.Insert()) + + bodyBytes, err := common.Marshal(map[string]interface{}{ + "mchNo": settingJeepayMchNoForTest, + "appId": settingJeepayAppIDForTest, + "mchOrderNo": topUp.TradeNo, + "state": "2", + "amount": 5000, + "signType": "MD5", + "sign": "BAD-SIGN", + }) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/api/jeepay/notify", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + JeepayNotify(c) + + require.NotEqual(t, http.StatusOK, w.Code) + + savedTopUp := model.GetTopUpByTradeNo(topUp.TradeNo) + require.NotNil(t, savedTopUp) + require.Equal(t, common.TopUpStatusPending, savedTopUp.Status) +} + +const ( + settingJeepayMchNoForTest = "M123456789" + settingJeepayAppIDForTest = "A123456789" + settingJeepayApiKeyForTest = "secret-key" +) + +func settingJeepayForTest() { + setting.JeepayBaseURL = "https://jeepay.example.com" + setting.JeepayMchNo = settingJeepayMchNoForTest + setting.JeepayAppID = settingJeepayAppIDForTest + setting.JeepayAPIKey = settingJeepayApiKeyForTest +} diff --git a/model/option.go b/model/option.go index 967fa0aa6708..1f8b76f8f00c 100644 --- a/model/option.go +++ b/model/option.go @@ -89,6 +89,15 @@ func InitOptionMap() { common.OptionMap["CreemProducts"] = setting.CreemProducts common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret + common.OptionMap["JeepayBaseURL"] = setting.JeepayBaseURL + common.OptionMap["JeepayMchNo"] = setting.JeepayMchNo + common.OptionMap["JeepayAppID"] = setting.JeepayAppID + common.OptionMap["JeepayAPIKey"] = setting.JeepayAPIKey + common.OptionMap["JeepayWayCode"] = setting.JeepayWayCode + common.OptionMap["JeepayNotifyURL"] = setting.JeepayNotifyURL + common.OptionMap["JeepayReturnURL"] = setting.JeepayReturnURL + common.OptionMap["JeepayMinTopUp"] = strconv.Itoa(setting.JeepayMinTopUp) + common.OptionMap["JeepayOrderTimeoutMinutes"] = strconv.Itoa(setting.JeepayOrderTimeoutMinutes) common.OptionMap["WaffoEnabled"] = strconv.FormatBool(setting.WaffoEnabled) common.OptionMap["WaffoApiKey"] = setting.WaffoApiKey common.OptionMap["WaffoPrivateKey"] = setting.WaffoPrivateKey @@ -374,6 +383,24 @@ func updateOptionMap(key string, value string) (err error) { setting.CreemTestMode = value == "true" case "CreemWebhookSecret": setting.CreemWebhookSecret = value + case "JeepayBaseURL": + setting.JeepayBaseURL = value + case "JeepayMchNo": + setting.JeepayMchNo = value + case "JeepayAppID": + setting.JeepayAppID = value + case "JeepayAPIKey": + setting.JeepayAPIKey = value + case "JeepayWayCode": + setting.JeepayWayCode = value + case "JeepayNotifyURL": + setting.JeepayNotifyURL = value + case "JeepayReturnURL": + setting.JeepayReturnURL = value + case "JeepayMinTopUp": + setting.JeepayMinTopUp, _ = strconv.Atoi(value) + case "JeepayOrderTimeoutMinutes": + setting.JeepayOrderTimeoutMinutes, _ = strconv.Atoi(value) case "WaffoEnabled": setting.WaffoEnabled = value == "true" case "WaffoApiKey": diff --git a/model/topup.go b/model/topup.go index d8c92bfe6517..0a437b558034 100644 --- a/model/topup.go +++ b/model/topup.go @@ -435,3 +435,62 @@ func RechargeWaffo(tradeNo string) (err error) { return nil } + +func RechargeJeepay(tradeNo string) (err error) { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + + var quotaToAdd int + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + common.SysError("jeepay topup failed: " + err.Error()) + return errors.New("充值失败,请稍后重试") + } + + if quotaToAdd > 0 { + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Jeepay充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) + } + + return nil +} diff --git a/router/api-router.go b/router/api-router.go index 35d113768be7..8f0a96394736 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -48,6 +48,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) + apiRouter.POST("/jeepay/notify", controller.JeepayNotify) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) // Universal secure verification routes @@ -90,6 +91,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) + selfRoute.POST("/jeepay/pay", middleware.CriticalRateLimit(), controller.RequestJeepayPay) + selfRoute.GET("/jeepay/status/:tradeNo", controller.GetJeepayPayStatus) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) diff --git a/setting/payment_jeepay.go b/setting/payment_jeepay.go new file mode 100644 index 000000000000..f35bac4eda1c --- /dev/null +++ b/setting/payment_jeepay.go @@ -0,0 +1,11 @@ +package setting + +var JeepayBaseURL = "https://pay.jeepay.vip" +var JeepayMchNo = "" +var JeepayAppID = "" +var JeepayAPIKey = "" +var JeepayWayCode = "QR_CASHIER" +var JeepayNotifyURL = "" +var JeepayReturnURL = "" +var JeepayMinTopUp = 1 +var JeepayOrderTimeoutMinutes = 5 diff --git a/web/src/assets/jeepay.svg b/web/src/assets/jeepay.svg new file mode 100644 index 000000000000..5a44a1ccb651 --- /dev/null +++ b/web/src/assets/jeepay.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 928d58a74bb0..9c57208a69c4 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway'; import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; +import SettingsPaymentGatewayJeepay from '../../pages/Setting/Payment/SettingsPaymentGatewayJeepay'; import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo'; import { API, showError, toBoolean } from '../../helpers'; import { useTranslation } from 'react-i18next'; @@ -48,6 +49,15 @@ const PaymentSetting = () => { StripeUnitPrice: 8.0, StripeMinTopUp: 1, StripePromotionCodesEnabled: false, + + JeepayBaseURL: '', + JeepayMchNo: '', + JeepayAppID: '', + JeepayAPIKey: '', + JeepayWayCode: 'WEB_CASHIER', + JeepayNotifyURL: '', + JeepayReturnURL: '', + JeepayMinTopUp: 1, }); let [loading, setLoading] = useState(false); @@ -96,6 +106,7 @@ const PaymentSetting = () => { case 'MinTopUp': case 'StripeUnitPrice': case 'StripeMinTopUp': + case 'JeepayMinTopUp': newInputs[item.key] = parseFloat(item.value); break; default: @@ -147,6 +158,9 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129b33d3..48d47637823f 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -49,6 +49,7 @@ import { IconGift } from '@douyinfe/semi-icons'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; import { getCurrencyConfig } from '../../helpers/render'; import SubscriptionPlansCard from './SubscriptionPlansCard'; +import jeepayIcon from '../../assets/jeepay.svg'; const { Text } = Typography; @@ -56,6 +57,7 @@ const RechargeCard = ({ t, enableOnlineTopUp, enableStripeTopUp, + enableJeepayTopUp, enableCreemTopUp, creemProducts, creemPreTopUp, @@ -227,19 +229,19 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? ( + ) : enableOnlineTopUp || enableStripeTopUp || enableJeepayTopUp || enableCreemTopUp || enableWaffoTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} >
- {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableJeepayTopUp || enableWaffoTopUp) && ( m.type !== 'waffo').map((payMethod) => { const minTopupVal = Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; + const isJeepay = payMethod.type === 'jeepay'; const disabled = - (!enableOnlineTopUp && !isStripe) || + (!enableOnlineTopUp && !isStripe && !isJeepay) || (!enableStripeTopUp && isStripe) || + (!enableJeepayTopUp && isJeepay) || minTopupVal > Number(topUpCount || 0); const buttonEl = ( @@ -320,6 +324,12 @@ const RechargeCard = ({ ) : payMethod.type === 'stripe' ? ( + ) : payMethod.type === 'jeepay' ? ( + Jeepay ) : ( )} - {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableJeepayTopUp || enableWaffoTopUp) && ( diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 0348e3c8dd93..77904ba57f68 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -39,6 +39,7 @@ import InvitationCard from './InvitationCard'; import TransferModal from './modals/TransferModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal'; import TopupHistoryModal from './modals/TopupHistoryModal'; +import JeepayQRCodeModal from './modals/JeepayQRCodeModal'; const TopUp = () => { const { t } = useTranslation(); @@ -63,6 +64,9 @@ const TopUp = () => { const [enableStripeTopUp, setEnableStripeTopUp] = useState( statusState?.status?.enable_stripe_topup || false, ); + const [enableJeepayTopUp, setEnableJeepayTopUp] = useState( + statusState?.status?.enable_jeepay_topup || false, + ); const [statusLoading, setStatusLoading] = useState(true); // Creem 相关状态 @@ -83,6 +87,15 @@ const TopUp = () => { const [paymentLoading, setPaymentLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false); const [payMethods, setPayMethods] = useState([]); + const [jeepayQRCodeOpen, setJeepayQRCodeOpen] = useState(false); + const [jeepayQRCodeData, setJeepayQRCodeData] = useState({ + qrCodeUrl: '', + orderId: '', + wayCode: '', + money: '', + expiredTime: null, + expireAt: null, + }); const affFetchedRef = useRef(false); @@ -162,6 +175,11 @@ const TopUp = () => { showError(t('管理员未开启Stripe充值!')); return; } + } else if (payment === 'jeepay') { + if (!enableJeepayTopUp) { + showError(t('管理员未开启 Jeepay 充值!')); + return; + } } else { if (!enableOnlineTopUp) { showError(t('管理员未开启在线充值!')); @@ -192,12 +210,10 @@ const TopUp = () => { const onlineTopUp = async () => { if (payWay === 'stripe') { - // Stripe 支付处理 if (amount === 0) { await getStripeAmount(); } } else { - // 普通支付处理 if (amount === 0) { await getAmount(); } @@ -211,13 +227,18 @@ const TopUp = () => { try { let res; if (payWay === 'stripe') { - // Stripe 支付请求 res = await API.post('/api/user/stripe/pay', { amount: parseInt(topUpCount), payment_method: 'stripe', }); + } else if (payWay === 'jeepay') { + const selectedMethod = payMethods.find((method) => method.type === 'jeepay'); + res = await API.post('/api/user/jeepay/pay', { + amount: parseInt(topUpCount), + payment_method: 'jeepay', + way_code: selectedMethod?.way_code || 'WEB_CASHIER', + }); } else { - // 普通支付请求 res = await API.post('/api/user/pay', { amount: parseInt(topUpCount), payment_method: payWay, @@ -228,10 +249,22 @@ const TopUp = () => { const { message, data } = res.data; if (message === 'success') { if (payWay === 'stripe') { - // Stripe 支付回调处理 window.open(data.pay_link, '_blank'); + } else if (payWay === 'jeepay') { + if (data?.way_code && ['QR_CASHIER', 'WX_NATIVE', 'ALI_QR'].includes(data.way_code)) { + setJeepayQRCodeData({ + qrCodeUrl: data.qr_code_url || data.payment_url || '', + orderId: data.order_id || '', + wayCode: data.way_code, + money: data.money || '', + expiredTime: data.expired_time || null, + expireAt: data.expire_at || null, + }); + setJeepayQRCodeOpen(true); + } else { + window.open(data.payment_url, '_blank'); + } } else { - // 普通支付表单提交 let params = data; let url = res.data.url; let form = document.createElement('form'); @@ -284,7 +317,6 @@ const TopUp = () => { showError(t('请选择产品')); return; } - // Validate product has required fields if (!selectedCreemProduct.productId) { showError(t('产品配置错误,请联系管理员')); return; @@ -347,7 +379,6 @@ const TopUp = () => { }; const processCreemCallback = (data) => { - // 与 Stripe 保持一致的实现方式 window.open(data.checkout_url, '_blank'); }; @@ -382,10 +413,8 @@ const TopUp = () => { setBillingPreference( res.data.data?.billing_preference || 'subscription_first', ); - // Active subscriptions const activeSubs = res.data.data?.subscriptions || []; setActiveSubscriptions(activeSubs); - // All subscriptions (including expired) const allSubs = res.data.data?.all_subscriptions || []; setAllSubscriptions(allSubs); } @@ -416,37 +445,31 @@ const TopUp = () => { } }; - // 获取充值配置信息 const getTopupInfo = async () => { try { const res = await API.get('/api/user/topup/info'); - const { message, data, success } = res.data; + const { data, success } = res.data; if (success) { setTopupInfo({ amount_options: data.amount_options || [], discount: data.discount || {}, }); - // 处理支付方式 let payMethods = data.pay_methods || []; try { if (typeof payMethods === 'string') { payMethods = JSON.parse(payMethods); } if (payMethods && payMethods.length > 0) { - // 检查name和type是否为空 payMethods = payMethods.filter((method) => { return method.name && method.type; }); - // 如果没有color,则设置默认颜色 payMethods = payMethods.map((method) => { - // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; - // Stripe 的最小充值从后端字段回填 if ( method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0) @@ -474,22 +497,33 @@ const TopUp = () => { payMethods = []; } - // 如果启用了 Stripe 支付,添加到支付方法列表 - // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它 + const normalizedPayMethods = payMethods.map((method) => { + if (method.type === 'jeepay') { + return { + ...method, + way_code: method.way_code || data.jeepay_way_code || 'WEB_CASHIER', + }; + } + return method; + }); - setPayMethods(payMethods); + setPayMethods(normalizedPayMethods); const enableStripeTopUp = data.enable_stripe_topup || false; + const enableJeepayTopUp = data.enable_jeepay_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; const enableCreemTopUp = data.enable_creem_topup || false; const minTopUpValue = enableOnlineTopUp ? data.min_topup : enableStripeTopUp ? data.stripe_min_topup + : enableJeepayTopUp + ? data.jeepay_min_topup : data.enable_waffo_topup ? data.waffo_min_topup : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); + setEnableJeepayTopUp(enableJeepayTopUp); setEnableCreemTopUp(enableCreemTopUp); const enableWaffoTopUp = data.enable_waffo_topup || false; setEnableWaffoTopUp(enableWaffoTopUp); @@ -498,7 +532,6 @@ const TopUp = () => { setMinTopUp(minTopUpValue); setTopUpCount(minTopUpValue); - // 设置 Creem 产品 try { const products = JSON.parse(data.creem_products || '[]'); setCreemProducts(products); @@ -506,18 +539,15 @@ const TopUp = () => { setCreemProducts([]); } - // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项 if (topupInfo.amount_options.length === 0) { setPresetAmounts(generatePresetAmounts(minTopUpValue)); } - // 初始化显示实付金额 getAmount(minTopUpValue); } catch (e) { setPayMethods([]); } - // 如果有自定义充值数量选项,使用它们替换默认的预设选项 if (data.amount_options && data.amount_options.length > 0) { const customPresets = data.amount_options.map((amount) => ({ value: amount, @@ -533,7 +563,6 @@ const TopUp = () => { } }; - // 获取邀请链接 const getAffLink = async () => { const res = await API.get('/api/user/aff'); const { success, message, data } = res.data; @@ -545,7 +574,6 @@ const TopUp = () => { } }; - // 划转邀请额度 const transfer = async () => { if (transferAmount < getQuotaPerUnit()) { showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit())); @@ -564,13 +592,11 @@ const TopUp = () => { } }; - // 复制邀请链接 const handleAffLinkClick = async () => { await copy(affLink); showSuccess(t('邀请链接已复制到剪切板')); }; - // URL 参数自动打开账单弹窗(支付回跳时触发) useEffect(() => { if (searchParams.get('show_history') === 'true') { setOpenHistory(true); @@ -580,7 +606,6 @@ const TopUp = () => { }, []); useEffect(() => { - // 始终获取最新用户数据,确保余额等统计信息准确 getUserQuota().then(); setTransferAmount(getQuotaPerUnit()); }, []); @@ -591,7 +616,6 @@ const TopUp = () => { getAffLink().then(); }, []); - // 在 statusState 可用时获取充值信息 useEffect(() => { getTopupInfo().then(); getSubscriptionPlans().then(); @@ -600,12 +624,8 @@ const TopUp = () => { useEffect(() => { if (statusState?.status) { - // const minTopUpValue = statusState.status.min_topup || 1; - // setMinTopUp(minTopUpValue); - // setTopUpCount(minTopUpValue); setTopUpLink(statusState.status.top_up_link || ''); setPriceRatio(statusState.status.price || 1); - setStatusLoading(false); } }, [statusState?.status]); @@ -683,28 +703,30 @@ const TopUp = () => { setOpenHistory(false); }; + const handleJeepayPaid = async () => { + setJeepayQRCodeOpen(false); + await getUserQuota(); + setOpenHistory(true); + }; + const handleCreemCancel = () => { setCreemOpen(false); setSelectedCreemProduct(null); }; - // 选择预设充值额度 const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; setAmount(discountedAmount); }; - // 格式化大数字显示 const formatLargeNumber = (num) => { return num.toString(); }; - // 根据最小充值金额生成预设充值额度选项 const generatePresetAmounts = (minAmount) => { const multipliers = [1, 5, 10, 30, 50, 100, 300, 500]; return multipliers.map((multiplier) => ({ @@ -714,7 +736,6 @@ const TopUp = () => { return (
- {/* 划转模态框 */} { setTransferAmount={setTransferAmount} /> - {/* 充值确认模态框 */} { discountRate={topupInfo?.discount?.[topUpCount] || 1.0} /> - {/* 充值账单模态框 */} - {/* Creem 充值确认模态框 */} { )} - {/* 主布局区域 */} + setJeepayQRCodeOpen(false)} + qrCodeUrl={jeepayQRCodeData.qrCodeUrl} + orderId={jeepayQRCodeData.orderId} + wayCode={jeepayQRCodeData.wayCode} + money={jeepayQRCodeData.money} + expiredTime={jeepayQRCodeData.expiredTime} + expireAt={jeepayQRCodeData.expireAt} + onPaid={handleJeepayPaid} + /> +
{ + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + + if (!visible || !orderId) { + expireAtRef.current = null; + setRemainingSeconds(null); + setIsExpired(false); + return undefined; + } + + const expireAtMs = Number(expireAt) > 0 + ? Number(expireAt) * 1000 + : Date.now() + (Number(expiredTime) || 0) * 1000; + expireAtRef.current = expireAtMs; + setIsExpired(Date.now() >= expireAtMs); + + const markExpired = () => { + setIsExpired(true); + setRemainingSeconds(0); + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + const updateCountdown = () => { + const currentLeft = Math.max( + 0, + Math.ceil((expireAtRef.current - Date.now()) / 1000), + ); + setRemainingSeconds(currentLeft); + if (currentLeft <= 0) { + markExpired(); + } + }; + + updateCountdown(); + countdownTimerRef.current = setInterval(updateCountdown, 1000); + + const pollStatus = async () => { + if (Date.now() > expireAtRef.current) { + markExpired(); + return; + } + + try { + const res = await API.get(`/api/user/jeepay/status/${encodeURIComponent(orderId)}`); + if (!res?.data?.success) { + return; + } + const status = res.data?.data?.status; + if (status === 'success') { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + Toast.success({ content: t('支付成功') }); + onPaid?.(); + } else if (status === 'failed' || status === 'expired') { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (status === 'expired') { + markExpired(); + } else { + showError(t('订单状态已变更,请重新下单')); + } + } + } catch (error) { + // ignore transient polling errors + } + }; + + pollStatus(); + pollTimerRef.current = setInterval(pollStatus, 3000); + + return () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + }; + }, [visible, orderId, expiredTime, expireAt, onPaid, t]); + + return ( + +
+ {t(payTips[wayCode] || '请使用扫码支付')} + + {money !== '' && money !== null && money !== undefined ? ( +
+ {t('实付金额')} + + {Number(money).toFixed(2)} {t('元')} + +
+ ) : null} + + {isExpired ? ( +
+ {t('二维码已过期')} + {t('请重新下单获取新的支付码')} +
+ ) : qrCodeUrl ? ( + + ) : ( + {t('二维码内容为空')} + )} + +
+ {orderId ? ( + + {t('订单号')}:{orderId} + + ) : null} + {remainingSeconds !== null ? ( + + {t('支付剩余时间')}:{Math.floor(remainingSeconds / 60)}:{String(remainingSeconds % 60).padStart(2, '0')} + + ) : null} +
+ + + {t('该码只能扫一次,再次扫码需重新下单!')} + +
+
+ ); +} diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 8bd5455c7f84..a17c425ed536 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -21,6 +21,7 @@ import React from 'react'; import { Modal, Typography, Card, Skeleton } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard } from 'lucide-react'; +import jeepayIcon from '../../../assets/jeepay.svg'; const { Text } = Typography; @@ -140,6 +141,13 @@ const PaymentConfirmModal = ({ size={16} color='#635BFF' /> + ) : payMethod.type === 'jeepay' ? ( + Jeepay ) : ( . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Col, Form, Row, Select, Spin, Typography } from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +const jeepayWayCodeOptions = [ + { label: '聚合扫码(QR_CASHIER)', value: 'QR_CASHIER' }, + { label: '收银台(WEB_CASHIER)', value: 'WEB_CASHIER' }, + { label: '微信扫码(WX_NATIVE)', value: 'WX_NATIVE' }, + { label: '支付宝扫码(ALI_QR)', value: 'ALI_QR' }, +]; + +export default function SettingsPaymentGatewayJeepay(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + JeepayBaseURL: 'https://pay.jeepay.vip', + JeepayMchNo: '', + JeepayAppID: '', + JeepayAPIKey: '', + JeepayWayCode: 'QR_CASHIER', + JeepayMinTopUp: 1, + JeepayOrderTimeoutMinutes: 5, + }); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + JeepayBaseURL: props.options.JeepayBaseURL || 'https://pay.jeepay.vip', + JeepayMchNo: props.options.JeepayMchNo || '', + JeepayAppID: props.options.JeepayAppID || '', + JeepayAPIKey: props.options.JeepayAPIKey || '', + JeepayWayCode: props.options.JeepayWayCode || 'QR_CASHIER', + JeepayMinTopUp: parseInt(props.options.JeepayMinTopUp) || 1, + JeepayOrderTimeoutMinutes: parseInt(props.options.JeepayOrderTimeoutMinutes) || 5, + }; + setInputs(currentInputs); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitJeepaySetting = async () => { + setLoading(true); + try { + const options = [ + { key: 'JeepayBaseURL', value: inputs.JeepayBaseURL || '' }, + { key: 'JeepayMchNo', value: inputs.JeepayMchNo || '' }, + { key: 'JeepayAppID', value: inputs.JeepayAppID || '' }, + ...(inputs.JeepayAPIKey + ? [{ key: 'JeepayAPIKey', value: inputs.JeepayAPIKey }] + : []), + { key: 'JeepayWayCode', value: inputs.JeepayWayCode || 'QR_CASHIER' }, + { key: 'JeepayMinTopUp', value: String(inputs.JeepayMinTopUp || 1) }, + { + key: 'JeepayOrderTimeoutMinutes', + value: String(inputs.JeepayOrderTimeoutMinutes || 5), + }, + ]; + + const results = await Promise.all( + options.map((opt) => + API.put('/api/option/', { + key: opt.key, + value: opt.value, + }), + ), + ); + + const errorResults = results.filter((res) => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach((res) => showError(res.data.message)); + } else { + showSuccess(t('更新成功')); + props.refresh?.(); + } + } catch (error) { + showError(t('更新失败')); + } finally { + setLoading(false); + } + }; + + return ( + + (formApiRef.current = api)} + > + + + + {t('Jeepay')} + + {t('是由计全开源的聚合支付系统,支持多种支付方式。快速上线使用,可申请计全官方通道接口:')} + + {t('计全付')} + + , + + {t('官方注册流程')} + + 。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}