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 = () => {