diff --git a/.gitignore b/.gitignore index c17652a21a33..483c053500b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ web/dist .env one-api new-api +new-api-local /__debug_bin* .DS_Store tiktoken_cache diff --git a/controller/subscription_payment_xunhupay.go b/controller/subscription_payment_xunhupay.go new file mode 100644 index 000000000000..8783f6905407 --- /dev/null +++ b/controller/subscription_payment_xunhupay.go @@ -0,0 +1,247 @@ +package controller + +import ( + "fmt" + "net/http" + "net/url" + "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/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +func SubscriptionRequestXunhuPay(c *gin.Context) { + var req SubscriptionEpayPayRequest + if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + + plan, err := model.GetSubscriptionPlanById(req.PlanId) + if err != nil { + common.ApiError(c, err) + return + } + if !plan.Enabled { + common.ApiErrorMsg(c, "套餐未启用") + return + } + if plan.PriceAmount < 0.01 { + common.ApiErrorMsg(c, "套餐金额过低") + return + } + if !operation_setting.ContainsPayMethod(req.PaymentMethod) { + common.ApiErrorMsg(c, "支付方式不存在") + return + } + + userId := c.GetInt("id") + if plan.MaxPurchasePerUser > 0 { + count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id) + if err != nil { + common.ApiError(c, err) + return + } + if count >= int64(plan.MaxPurchasePerUser) { + common.ApiErrorMsg(c, "已达到该套餐购买上限") + return + } + } + + if operation_setting.XunhuPayAppId == "" || operation_setting.XunhuPayAppSecret == "" || operation_setting.XunhuPayGateway == "" { + common.ApiErrorMsg(c, "当前管理员未配置虎皮椒支付信息") + return + } + + callBackAddress := service.GetCallbackAddress() + returnUrl := callBackAddress + "/api/subscription/xunhupay/return" + notifyUrl := callBackAddress + "/api/subscription/xunhupay/notify" + + tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) + tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo) + + paymentType := req.PaymentMethod + if paymentType == "wxpay" { + paymentType = "wechat" + } + + nowTime := fmt.Sprintf("%d", time.Now().Unix()) + totalFee := strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64) + + params := map[string]string{ + "version": "1.1", + "appid": operation_setting.XunhuPayAppId, + "trade_order_id": tradeNo, + "total_fee": totalFee, + "title": fmt.Sprintf("SUB:%s", plan.Title), + "time": nowTime, + "notify_url": notifyUrl, + "return_url": returnUrl, + "nonce_str": common.GetRandomString(16), + "type": paymentType, + } + params["hash"] = generateXunhuHash(params, operation_setting.XunhuPayAppSecret) + + order := &model.SubscriptionOrder{ + UserId: userId, + PlanId: plan.Id, + Money: plan.PriceAmount, + TradeNo: tradeNo, + PaymentMethod: req.PaymentMethod, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := order.Insert(); err != nil { + common.ApiErrorMsg(c, "创建订单失败") + return + } + + // Build gateway URL + gateway := operation_setting.XunhuPayGateway + if !strings.HasSuffix(gateway, "/") && !strings.HasSuffix(gateway, "do.html") { + gateway += "/" + } + if !strings.HasSuffix(gateway, "do.html") { + gateway += "payment/do.html" + } + + client := service.GetHttpClient() + formData := url.Values{} + for k, v := range params { + formData.Set(k, v) + } + + resp, err := client.PostForm(gateway, formData) + if err != nil { + _ = model.ExpireSubscriptionOrder(tradeNo) + common.ApiErrorMsg(c, "网络请求失败,无法拉起支付") + return + } + defer resp.Body.Close() + + var result struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Url string `json:"url"` + Hash string `json:"hash"` + } + if err = common.DecodeJson(resp.Body, &result); err != nil { + _ = model.ExpireSubscriptionOrder(tradeNo) + common.ApiErrorMsg(c, "支付网关返回格式错误") + return + } + if result.Errcode != 0 { + _ = model.ExpireSubscriptionOrder(tradeNo) + common.ApiErrorMsg(c, "拉起支付失败: "+result.Errmsg) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "success", "url": result.Url}) +} + +func SubscriptionXunhuPayNotify(c *gin.Context) { + var params map[string]string + + if c.Request.Method == "POST" { + if err := c.Request.ParseForm(); err != nil { + _, _ = c.Writer.Write([]byte("fail")) + return + } + params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.PostForm.Get(t) + return r + }, map[string]string{}) + } else { + params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + } + + if len(params) == 0 { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + reqHash := params["hash"] + if reqHash == "" { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + sign := generateXunhuHash(params, operation_setting.XunhuPayAppSecret) + if sign != reqHash { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + if params["status"] == "OD" { + tradeNo := params["trade_order_id"] + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + if err := model.CompleteSubscriptionOrder(tradeNo, common.GetJsonString(params)); err != nil { + _, _ = c.Writer.Write([]byte("fail")) + return + } + } + _, _ = c.Writer.Write([]byte("success")) +} + +func SubscriptionXunhuPayReturn(c *gin.Context) { + var params map[string]string + + if c.Request.Method == "POST" { + if err := c.Request.ParseForm(); err != nil { + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + return + } + params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.PostForm.Get(t) + return r + }, map[string]string{}) + } else { + params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + } + + if len(params) == 0 { + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + return + } + + reqHash := params["hash"] + if reqHash == "" { + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + return + } + + sign := generateXunhuHash(params, operation_setting.XunhuPayAppSecret) + if sign != reqHash { + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + return + } + + if params["status"] == "OD" { + tradeNo := params["trade_order_id"] + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + if err := model.CompleteSubscriptionOrder(tradeNo, common.GetJsonString(params)); err != nil { + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") + return + } + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success") + return + } + c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending") +} diff --git a/controller/topup.go b/controller/topup.go index e7a392a4d31d..b40093d4e5c6 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -78,24 +78,69 @@ func GetTopUpInfo(c *gin.Context) { } } + enableEpay := operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "" + enableXunhuPay := operation_setting.XunhuPayAppId != "" && operation_setting.XunhuPayAppSecret != "" && operation_setting.XunhuPayGateway != "" + + // 如果启用了虎皮椒支付,根据 XunhuPayMethod 自动注入对应支付方式 + if enableXunhuPay { + xunhuMethod := operation_setting.XunhuPayMethod + if xunhuMethod == "" { + xunhuMethod = "both" + } + minTopupStr := strconv.Itoa(operation_setting.MinTopUp) + if xunhuMethod == "alipay" || xunhuMethod == "both" { + hasAlipay := false + for _, m := range payMethods { + if m["type"] == "alipay" { + hasAlipay = true + break + } + } + if !hasAlipay { + payMethods = append(payMethods, map[string]string{ + "name": "支付宝", + "type": "alipay", + "min_topup": minTopupStr, + }) + } + } + if xunhuMethod == "wxpay" || xunhuMethod == "both" { + hasWxpay := false + for _, m := range payMethods { + if m["type"] == "wxpay" { + hasWxpay = true + break + } + } + if !hasWxpay { + payMethods = append(payMethods, map[string]string{ + "name": "微信支付", + "type": "wxpay", + "min_topup": minTopupStr, + }) + } + } + } + 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_waffo_topup": enableWaffo, + "enable_online_topup": enableEpay || enableXunhuPay, + "enable_xunhupay_topup": enableXunhuPay, + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", + "enable_waffo_topup": enableWaffo, "waffo_pay_methods": func() interface{} { if enableWaffo { return setting.GetWaffoPayMethods() } return nil }(), - "creem_products": setting.CreemProducts, - "pay_methods": payMethods, - "min_topup": operation_setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, - "waffo_min_topup": setting.WaffoMinTopUp, - "amount_options": operation_setting.GetPaymentSetting().AmountOptions, - "discount": operation_setting.GetPaymentSetting().AmountDiscount, + "creem_products": setting.CreemProducts, + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "waffo_min_topup": setting.WaffoMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, } common.ApiSuccess(c, data) } @@ -463,4 +508,3 @@ func AdminCompleteTopUp(c *gin.Context) { } common.ApiSuccess(c, nil) } - diff --git a/controller/topup_xunhupay.go b/controller/topup_xunhupay.go new file mode 100644 index 000000000000..895447782bc2 --- /dev/null +++ b/controller/topup_xunhupay.go @@ -0,0 +1,262 @@ +package controller + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "log" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/shopspring/decimal" +) + +type XunhuPayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` +} + +func GetXunhuPayMoney(amount int64, group string) float64 { + return getPayMoney(amount, group) +} + +func GetXunhuPayMinTopup() int64 { + return getMinTopup() +} + +func generateXunhuHash(data map[string]string, appSecret string) string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for _, k := range keys { + if k == "hash" || data[k] == "" { + continue + } + if sb.Len() > 0 { + sb.WriteString("&") + } + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(data[k]) + } + sb.WriteString(appSecret) + + h := md5.New() + h.Write([]byte(sb.String())) + return hex.EncodeToString(h.Sum(nil)) +} + +func RequestXunhuPay(c *gin.Context) { + var req XunhuPayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.Amount < GetXunhuPayMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", GetXunhuPayMinTopup())}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := GetXunhuPayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + if !operation_setting.ContainsPayMethod(req.PaymentMethod) { + c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + return + } + + callBackAddress := service.GetCallbackAddress() + returnUrl := system_setting.ServerAddress + "/console/log" + notifyUrl := callBackAddress + "/api/user/xunhupay/notify" + + tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) + tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) + + if operation_setting.XunhuPayAppId == "" || operation_setting.XunhuPayAppSecret == "" || operation_setting.XunhuPayGateway == "" { + c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置虎皮椒支付信息"}) + return + } + + paymentType := req.PaymentMethod + if paymentType == "wxpay" { + paymentType = "wechat" + } + + nowTime := fmt.Sprintf("%d", time.Now().Unix()) + totalFee := strconv.FormatFloat(payMoney, 'f', 2, 64) + + params := map[string]string{ + "version": "1.1", + "appid": operation_setting.XunhuPayAppId, + "trade_order_id": tradeNo, + "total_fee": totalFee, + "title": fmt.Sprintf("TUC%d", req.Amount), + "time": nowTime, + "notify_url": notifyUrl, + "return_url": returnUrl, + "nonce_str": common.GetRandomString(16), + "type": paymentType, + } + params["hash"] = generateXunhuHash(params, operation_setting.XunhuPayAppSecret) + + // Send request to gateway + gateway := operation_setting.XunhuPayGateway + if !strings.HasSuffix(gateway, "/") && !strings.HasSuffix(gateway, "do.html") { + gateway += "/" + } + if !strings.HasSuffix(gateway, "do.html") { + gateway += "payment/do.html" + } + + client := service.GetHttpClient() + formData := url.Values{} + for k, v := range params { + formData.Set(k, v) + } + + resp, err := client.PostForm(gateway, formData) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "网络请求失败,无法拉起微信/支付宝支付"}) + return + } + defer resp.Body.Close() + + var result struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Url string `json:"url"` + Hash string `json:"hash"` + } + err = common.DecodeJson(resp.Body, &result) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "支付网关返回格式错误"}) + return + } + if result.Errcode != 0 { + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败: " + result.Errmsg}) + return + } + + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount := decimal.NewFromInt(int64(amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + } + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: req.PaymentMethod, + CreateTime: time.Now().Unix(), + Status: "pending", + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + // Data here is the form hidden inputs, not needed for url redirect mode. Just return URL. + c.JSON(200, gin.H{"message": "success", "url": result.Url}) +} + +func XunhuPayNotify(c *gin.Context) { + var params map[string]string + + if c.Request.Method == "POST" { + if err := c.Request.ParseForm(); err != nil { + log.Println("虎皮椒回调POST解析失败:", err) + _, _ = c.Writer.Write([]byte("fail")) + return + } + params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.PostForm.Get(t) + return r + }, map[string]string{}) + } else { + params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + } + + if len(params) == 0 { + log.Println("虎皮椒回调参数为空") + _, _ = c.Writer.Write([]byte("fail")) + return + } + + reqHash := params["hash"] + if reqHash == "" { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + sign := generateXunhuHash(params, operation_setting.XunhuPayAppSecret) + if sign != reqHash { + log.Println("虎皮椒回调签名验证失败") + _, _ = c.Writer.Write([]byte("fail")) + return + } + + status := params["status"] + if status == "OD" { + tradeNo := params["trade_order_id"] + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil { + log.Printf("虎皮椒回调未找到订单: %v", tradeNo) + _, _ = c.Writer.Write([]byte("success")) + return + } + if topUp.Status == "pending" { + topUp.Status = "success" + err := topUp.Update() + if err != nil { + log.Printf("虎皮椒回调更新订单失败: %v", topUp) + return + } + dAmount := decimal.NewFromInt(int64(topUp.Amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) + err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) + if err != nil { + log.Printf("虎皮椒回调更新用户失败: %v", topUp) + return + } + model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用虎皮椒充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money)) + } + _, _ = c.Writer.Write([]byte("success")) + } else { + log.Printf("虎皮椒未完成回调状态: %s", status) + _, _ = c.Writer.Write([]byte("success")) + } +} diff --git a/model/option.go b/model/option.go index efa8c01daa7b..1d63a7f31d16 100644 --- a/model/option.go +++ b/model/option.go @@ -77,6 +77,10 @@ func InitOptionMap() { common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" common.OptionMap["EpayKey"] = "" + common.OptionMap["XunhuPayAppId"] = operation_setting.XunhuPayAppId + common.OptionMap["XunhuPayAppSecret"] = operation_setting.XunhuPayAppSecret + common.OptionMap["XunhuPayGateway"] = operation_setting.XunhuPayGateway + common.OptionMap["XunhuPayMethod"] = operation_setting.XunhuPayMethod common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) @@ -341,6 +345,14 @@ func updateOptionMap(key string, value string) (err error) { system_setting.WorkerValidKey = value case "PayAddress": operation_setting.PayAddress = value + case "XunhuPayAppId": + operation_setting.XunhuPayAppId = value + case "XunhuPayAppSecret": + operation_setting.XunhuPayAppSecret = value + case "XunhuPayGateway": + operation_setting.XunhuPayGateway = value + case "XunhuPayMethod": + operation_setting.XunhuPayMethod = value case "Chats": err = setting.UpdateChatsByJsonString(value) case "AutoGroups": diff --git a/router/api-router.go b/router/api-router.go index acc2241b515c..f9805e23ff05 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -64,6 +64,8 @@ func SetApiRouter(router *gin.Engine) { userRoute.GET("/logout", controller.Logout) userRoute.POST("/epay/notify", controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify) + userRoute.POST("/xunhupay/notify", controller.XunhuPayNotify) + userRoute.GET("/xunhupay/notify", controller.XunhuPayNotify) userRoute.GET("/groups", controller.GetUserGroups) selfRoute := userRoute.Group("/") @@ -86,6 +88,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) + selfRoute.POST("/xunhupay/pay", middleware.CriticalRateLimit(), controller.RequestXunhuPay) selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) @@ -141,6 +144,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionRoute.GET("/self", controller.GetSubscriptionSelf) subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference) subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) + subscriptionRoute.POST("/xunhupay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestXunhuPay) subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) } @@ -165,6 +169,10 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify) apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn) apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn) + apiRouter.POST("/subscription/xunhupay/notify", controller.SubscriptionXunhuPayNotify) + apiRouter.GET("/subscription/xunhupay/notify", controller.SubscriptionXunhuPayNotify) + apiRouter.GET("/subscription/xunhupay/return", controller.SubscriptionXunhuPayReturn) + apiRouter.POST("/subscription/xunhupay/return", controller.SubscriptionXunhuPayReturn) optionRoute := apiRouter.Group("/option") optionRoute.Use(middleware.RootAuth()) { diff --git a/setting/operation_setting/xunhupay_setting.go b/setting/operation_setting/xunhupay_setting.go new file mode 100644 index 000000000000..4d5f6abdcb53 --- /dev/null +++ b/setting/operation_setting/xunhupay_setting.go @@ -0,0 +1,8 @@ +package operation_setting + +// 虎皮椒支付独立配置 +var XunhuPayAppId = "" +var XunhuPayAppSecret = "" +var XunhuPayGateway = "" +// XunhuPayMethod 控制用户端显示的支付方式:alipay / wxpay / both(默认) +var XunhuPayMethod = "both" diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 928d58a74bb0..71b6678232f4 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -24,6 +24,7 @@ import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentG import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo'; +import SettingsPaymentGatewayXunhu from '../../pages/Setting/Payment/SettingsPaymentGatewayXunhu'; import { API, showError, toBoolean } from '../../helpers'; import { useTranslation } from 'react-i18next'; @@ -42,6 +43,11 @@ const PaymentSetting = () => { AmountOptions: '', AmountDiscount: '', + XunhuPayAppId: '', + XunhuPayAppSecret: '', + XunhuPayGateway: '', + XunhuPayMethod: 'both', + StripeApiSecret: '', StripeWebhookSecret: '', StripePriceId: '', @@ -147,6 +153,9 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129b33d3..06ceb448907c 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -55,6 +55,7 @@ const { Text } = Typography; const RechargeCard = ({ t, enableOnlineTopUp, + enableXunhupayTopUp, enableStripeTopUp, enableCreemTopUp, creemProducts, @@ -227,7 +228,10 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? ( + ) : enableOnlineTopUp || + enableStripeTopUp || + enableCreemTopUp || + enableWaffoTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} @@ -239,7 +243,11 @@ const RechargeCard = ({ - {payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && ( - - - - {payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; - const isStripe = payMethod.type === 'stripe'; - const disabled = - (!enableOnlineTopUp && !isStripe) || - (!enableStripeTopUp && isStripe) || - minTopupVal > Number(topUpCount || 0); + {payMethods && + payMethods.filter((m) => m.type !== 'waffo').length > 0 && ( + + + + {payMethods + .filter((m) => m.type !== 'waffo') + .map((payMethod) => { + const minTopupVal = + Number(payMethod.min_topup) || 0; + const isStripe = payMethod.type === 'stripe'; + const disabled = + (!enableOnlineTopUp && !isStripe) || + (!enableStripeTopUp && isStripe) || + minTopupVal > Number(topUpCount || 0); - const buttonEl = ( - - ); + const buttonEl = ( + + ); - return disabled && - minTopupVal > Number(topUpCount || 0) ? ( - - {buttonEl} - - ) : ( - - {buttonEl} - - ); - })} - - - - )} + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + + {buttonEl} + + ) : ( + + {buttonEl} + + ); + })} + + + + )} )} @@ -388,7 +401,9 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { const discount = - preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; @@ -404,7 +419,7 @@ const RechargeCard = ({ const s = JSON.parse(statusStr); usdRate = s?.usd_exchange_rate || 7; } - } catch (e) { } + } catch (e) {} let displayValue = preset.value; // 显示的数量 let displayActualPay = actualPay; @@ -455,7 +470,10 @@ const RechargeCard = ({ {hasDiscount && ( {t('折').includes('off') - ? ((1 - parseFloat(discount)) * 100).toFixed(1) + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) : (discount * 10).toFixed(1)} {t('折')} @@ -659,6 +677,7 @@ const RechargeCard = ({ plans={subscriptionPlans} payMethods={payMethods} enableOnlineTopUp={enableOnlineTopUp} + enableXunhupayTopUp={enableXunhupayTopUp} enableStripeTopUp={enableStripeTopUp} enableCreemTopUp={enableCreemTopUp} billingPreference={billingPreference} diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 9c50828372ba..eb9ef9d93c33 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -75,6 +75,7 @@ const SubscriptionPlansCard = ({ plans = [], payMethods = [], enableOnlineTopUp = false, + enableXunhupayTopUp = false, enableStripeTopUp = false, enableCreemTopUp = false, billingPreference, @@ -176,12 +177,31 @@ const SubscriptionPlansCard = ({ } setPaying(true); try { - const res = await API.post('/api/subscription/epay/pay', { + // 如果启用了虎皮椒且支付方式为微信/支付宝,走虎皮椒接口 + const isXunhupayMethod = + enableXunhupayTopUp && + (selectedEpayMethod === 'alipay' || selectedEpayMethod === 'wxpay'); + const apiUrl = isXunhupayMethod + ? '/api/subscription/xunhupay/pay' + : '/api/subscription/epay/pay'; + const res = await API.post(apiUrl, { plan_id: selectedPlan.plan.id, payment_method: selectedEpayMethod, }); if (res.data?.message === 'success') { - submitEpayForm({ url: res.data.url, params: res.data.data }); + if (isXunhupayMethod && res.data.url) { + // 虎皮椒返回直接跳转链接 + const isSafari = + navigator.userAgent.indexOf('Safari') > -1 && + navigator.userAgent.indexOf('Chrome') < 1; + if (isSafari) { + window.location.href = res.data.url; + } else { + window.open(res.data.url, '_blank'); + } + } else { + submitEpayForm({ url: res.data.url, params: res.data.data }); + } showSuccess(t('已发起支付')); closeBuy(); } else { diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 0348e3c8dd93..19248e5d77dc 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -76,6 +76,9 @@ const TopUp = () => { const [waffoPayMethods, setWaffoPayMethods] = useState([]); const [waffoMinTopUp, setWaffoMinTopUp] = useState(1); + // 虎皮椒 相关状态 + const [enableXunhupayTopUp, setEnableXunhupayTopUp] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); const [payWay, setPayWay] = useState(''); @@ -192,15 +195,9 @@ const TopUp = () => { const onlineTopUp = async () => { if (payWay === 'stripe') { - // Stripe 支付处理 - if (amount === 0) { - await getStripeAmount(); - } + if (amount === 0) await getStripeAmount(); } else { - // 普通支付处理 - if (amount === 0) { - await getAmount(); - } + if (amount === 0) await getAmount(); } if (topUpCount < minTopUp) { @@ -211,13 +208,21 @@ 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 ( + enableXunhupayTopUp && + (payWay === 'alipay' || payWay === 'wxpay') + ) { + // 虎皮椒支付 — 直接跳转 URL + res = await API.post('/api/user/xunhupay/pay', { + amount: parseInt(topUpCount), + payment_method: payWay, + }); } else { - // 普通支付请求 + // 易支付 form 提交 res = await API.post('/api/user/pay', { amount: parseInt(topUpCount), payment_method: payWay, @@ -228,21 +233,31 @@ const TopUp = () => { const { message, data } = res.data; if (message === 'success') { if (payWay === 'stripe') { - // Stripe 支付回调处理 window.open(data.pay_link, '_blank'); + } else if ( + res.config?.url?.includes('xunhupay') || + (enableXunhupayTopUp && res.data.url && !res.data.data) + ) { + // 虎皮椒返回直接跳转链接 + const isSafari = + navigator.userAgent.indexOf('Safari') > -1 && + navigator.userAgent.indexOf('Chrome') < 1; + if (isSafari) { + window.location.href = res.data.url; + } else { + window.open(res.data.url, '_blank'); + } } else { - // 普通支付表单提交 + // 易支付 form 提交 let params = data; let url = res.data.url; let form = document.createElement('form'); form.action = url; form.method = 'POST'; - let isSafari = + const isSafari = navigator.userAgent.indexOf('Safari') > -1 && navigator.userAgent.indexOf('Chrome') < 1; - if (!isSafari) { - form.target = '_blank'; - } + if (!isSafari) form.target = '_blank'; for (let key in params) { let input = document.createElement('input'); input.type = 'hidden'; @@ -317,32 +332,32 @@ const TopUp = () => { const waffoTopUp = async (payMethodIndex) => { try { - if (topUpCount < waffoMinTopUp) { - showError(t('充值数量不能小于') + waffoMinTopUp); - return; - } - setPaymentLoading(true); - const requestBody = { - amount: parseInt(topUpCount), - }; - if (payMethodIndex != null) { - requestBody.pay_method_index = payMethodIndex; - } - const res = await API.post('/api/user/waffo/pay', requestBody); - if (res !== undefined) { - const { message, data } = res.data; - if (message === 'success' && data?.payment_url) { - window.open(data.payment_url, '_blank'); - } else { - showError(data || t('支付请求失败')); - } + if (topUpCount < waffoMinTopUp) { + showError(t('充值数量不能小于') + waffoMinTopUp); + return; + } + setPaymentLoading(true); + const requestBody = { + amount: parseInt(topUpCount), + }; + if (payMethodIndex != null) { + requestBody.pay_method_index = payMethodIndex; + } + const res = await API.post('/api/user/waffo/pay', requestBody); + if (res !== undefined) { + const { message, data } = res.data; + if (message === 'success' && data?.payment_url) { + window.open(data.payment_url, '_blank'); } else { - showError(res); + showError(data || t('支付请求失败')); } + } else { + showError(res); + } } catch (e) { - showError(t('支付请求失败')); + showError(t('支付请求失败')); } finally { - setPaymentLoading(false); + setPaymentLoading(false); } }; @@ -478,17 +493,20 @@ const TopUp = () => { // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它 setPayMethods(payMethods); - const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; + const enableXunhupayTopUp = data.enable_xunhupay_topup || false; + const enableStripeTopUp = data.enable_stripe_topup || false; const enableCreemTopUp = data.enable_creem_topup || false; - const minTopUpValue = enableOnlineTopUp - ? data.min_topup - : enableStripeTopUp - ? data.stripe_min_topup - : data.enable_waffo_topup - ? data.waffo_min_topup - : 1; + const minTopUpValue = + enableOnlineTopUp || enableXunhupayTopUp + ? data.min_topup + : enableStripeTopUp + ? data.stripe_min_topup + : data.enable_waffo_topup + ? data.waffo_min_topup + : 1; setEnableOnlineTopUp(enableOnlineTopUp); + setEnableXunhupayTopUp(enableXunhupayTopUp); setEnableStripeTopUp(enableStripeTopUp); setEnableCreemTopUp(enableCreemTopUp); const enableWaffoTopUp = data.enable_waffo_topup || false; @@ -784,6 +802,7 @@ const TopUp = () => { API.put('/api/option/', { @@ -279,13 +273,6 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')} autosize /> - - - + diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayXunhu.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayXunhu.jsx new file mode 100644 index 000000000000..9e874722d656 --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayXunhu.jsx @@ -0,0 +1,191 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useRef } from 'react'; +import { Banner, Button, Form, Row, Col, Typography, Spin, Select } from '@douyinfe/semi-ui'; +const { Text } = Typography; +import { API, removeTrailingSlash, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsPaymentGatewayXunhu(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + XunhuPayAppId: '', + XunhuPayAppSecret: '', + XunhuPayGateway: '', + XunhuPayMethod: 'both', + }); + const [originInputs, setOriginInputs] = useState({}); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + XunhuPayAppId: props.options.XunhuPayAppId || '', + XunhuPayAppSecret: props.options.XunhuPayAppSecret || '', + XunhuPayGateway: props.options.XunhuPayGateway || '', + XunhuPayMethod: props.options.XunhuPayMethod || 'both', + }; + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitXunhuPaySetting = async () => { + if (!props.options.ServerAddress) { + showError(t('请先填写服务器地址')); + return; + } + + setLoading(true); + try { + const options = []; + + if (inputs.XunhuPayAppId !== '') { + options.push({ key: 'XunhuPayAppId', value: inputs.XunhuPayAppId }); + } + if (inputs.XunhuPayAppSecret !== '') { + options.push({ key: 'XunhuPayAppSecret', value: inputs.XunhuPayAppSecret }); + } + if (inputs.XunhuPayGateway !== '') { + options.push({ + key: 'XunhuPayGateway', + value: removeTrailingSlash(inputs.XunhuPayGateway), + }); + } + // 支付方式选择始终保存 + options.push({ key: 'XunhuPayMethod', value: inputs.XunhuPayMethod || 'both' }); + + if (options.length === 0) { + showError(t('没有需要更新的内容')); + setLoading(false); + return; + } + + const requestQueue = options.map((opt) => + API.put('/api/option/', { key: opt.key, value: opt.value }), + ); + const results = await Promise.all(requestQueue); + const errorResults = results.filter((res) => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach((res) => showError(res.data.message)); + } else { + showSuccess(t('更新成功')); + setOriginInputs({ ...inputs }); + props.refresh?.(); + } + } catch (error) { + showError(t('更新失败')); + } + setLoading(false); + }; + + return ( + +
(formApiRef.current = api)} + > + + + {t('虎皮椒(xunhupay)个人微信/支付宝收款接口。请前往')} + + {t('虎皮椒商户后台')} + + {t('获取 AppID 和 AppSecret。')} + + + + + + + + + + + + + + + + + +