支付宝当面付扫码支付集成 + 代码重构 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -72,17 +72,38 @@ func GetTopUpInfo(c *gin.Context) { | |||
| payMethods = append(payMethods, wechatMethod) | |||
| } | |||
| } | |||
| // 如果启用了支付宝支付,添加到支付方法列表 | |||
| if setting.IsAlipayConfigured() { | |||
| hasAlipay := false | |||
| for _, method := range payMethods { | |||
| if method["type"] == PaymentMethodAlipay { | |||
| hasAlipay = true | |||
| break | |||
| } | |||
| } | |||
| if !hasAlipay { | |||
| alipayMethod := map[string]string{ | |||
| "name": "Alipay", | |||
| "type": PaymentMethodAlipay, | |||
| "color": "rgba(var(--semi-blue-5), 1)", | |||
| "min_topup": strconv.Itoa(setting.AlipayMinTopUp), | |||
| } | |||
| payMethods = append(payMethods, alipayMethod) | |||
| } | |||
| } | |||
| data := gin.H{ | |||
| "enable_online_topup": enableOnlineTopup, | |||
| "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", | |||
| "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", | |||
| "enable_wechat_topup": setting.IsWechatPayConfigured(), | |||
| "enable_alipay_topup": setting.IsAlipayConfigured(), | |||
| "creem_products": setting.CreemProducts, | |||
| "pay_methods": payMethods, | |||
| "min_topup": operation_setting.MinTopUp, | |||
| "stripe_min_topup": setting.StripeMinTopUp, | |||
| "wechat_pay_min_topup": setting.WechatPayMinTopUp, | |||
| "alipay_pay_min_topup": setting.AlipayMinTopUp, | |||
| "amount_options": operation_setting.GetPaymentSetting().AmountOptions, | |||
| "discount": operation_setting.GetPaymentSetting().AmountDiscount, | |||
| } | |||
| @@ -143,15 +164,37 @@ func getPayMoney(amount int64, group string) float64 { | |||
| } | |||
| func getMinTopup() int64 { | |||
| minTopup := operation_setting.MinTopUp | |||
| return calcMinTopup(operation_setting.MinTopUp) | |||
| } | |||
| // calcMinTopup 计算最低充值数量(考虑 QuotaDisplayType 换算) | |||
| func calcMinTopup(baseMinTopup int) int64 { | |||
| minTopup := baseMinTopup | |||
| if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { | |||
| dMinTopup := decimal.NewFromInt(int64(minTopup)) | |||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | |||
| minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) | |||
| minTopup = minTopup * int(common.QuotaPerUnit) | |||
| } | |||
| return int64(minTopup) | |||
| } | |||
| // calcPayMoney 计算应付金额(元),使用指定的单价和最低充值 | |||
| func calcPayMoney(amount float64, group string, unitPrice float64) float64 { | |||
| originalAmount := amount | |||
| if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { | |||
| amount = amount / common.QuotaPerUnit | |||
| } | |||
| topupGroupRatio := common.GetTopupGroupRatio(group) | |||
| if topupGroupRatio == 0 { | |||
| topupGroupRatio = 1 | |||
| } | |||
| discount := 1.0 | |||
| if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { | |||
| if ds > 0 { | |||
| discount = ds | |||
| } | |||
| } | |||
| return amount * unitPrice * topupGroupRatio * discount | |||
| } | |||
| func RequestEpay(c *gin.Context) { | |||
| var req EpayRequest | |||
| err := c.ShouldBindJSON(&req) | |||
| @@ -0,0 +1,280 @@ | |||
| package controller | |||
| import ( | |||
| "context" | |||
| "encoding/base64" | |||
| "fmt" | |||
| "log" | |||
| "net/http" | |||
| "strconv" | |||
| "sync" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/go-pay/gopay" | |||
| "github.com/go-pay/gopay/alipay" | |||
| "github.com/thanhpk/randstr" | |||
| ) | |||
| const ( | |||
| PaymentMethodAlipay = "alipay" | |||
| ) | |||
| var alipayClientMu sync.Mutex | |||
| var alipayClient *alipay.Client | |||
| // ResetAlipayClient 重置支付宝客户端(配置变更时调用) | |||
| func ResetAlipayClient() { | |||
| alipayClientMu.Lock() | |||
| alipayClient = nil | |||
| alipayClientMu.Unlock() | |||
| } | |||
| func init() { | |||
| setting.OnAlipayConfigChanged = ResetAlipayClient | |||
| } | |||
| // getAlipayClient 获取或创建支付宝客户端 | |||
| func getAlipayClient() (*alipay.Client, error) { | |||
| alipayClientMu.Lock() | |||
| defer alipayClientMu.Unlock() | |||
| if alipayClient != nil { | |||
| return alipayClient, nil | |||
| } | |||
| if !setting.IsAlipayConfigured() { | |||
| return nil, fmt.Errorf("支付宝未配置") | |||
| } | |||
| client, err := alipay.NewClient(setting.AlipayAppID, setting.AlipayPrivateKey, true) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("创建支付宝客户端失败: %w", err) | |||
| } | |||
| client.SetCharset("utf-8"). | |||
| SetSignType(alipay.RSA2). | |||
| SetNotifyUrl(setting.AlipayNotifyURL) | |||
| // 设置支付宝公钥(用于回调验签) | |||
| pubKeyBytes, err := base64.StdEncoding.DecodeString(setting.AlipayPublicKey) | |||
| if err != nil { | |||
| pubKeyBytes = []byte(setting.AlipayPublicKey) | |||
| } | |||
| client.AutoVerifySign([]byte(wrapAsPEM(pubKeyBytes, "PUBLIC KEY"))) | |||
| alipayClient = client | |||
| return alipayClient, nil | |||
| } | |||
| // AlipayPayRequest 支付宝支付请求参数 | |||
| type AlipayPayRequest struct { | |||
| Amount int64 `json:"amount"` | |||
| } | |||
| // createAlipayPrecreateOrder 调用支付宝当面付预下单 API,返回二维码内容 | |||
| func createAlipayPrecreateOrder(client *alipay.Client, subject, tradeNo string, totalAmount string) (string, error) { | |||
| bm := make(gopay.BodyMap) | |||
| bm.Set("subject", subject). | |||
| Set("out_trade_no", tradeNo). | |||
| Set("total_amount", totalAmount). | |||
| Set("product_code", "FACE_TO_FACE_PAYMENT") | |||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| defer cancel() | |||
| rsp, err := client.TradePrecreate(ctx, bm) | |||
| if err != nil { | |||
| return "", fmt.Errorf("支付宝当面付下单失败: %w", err) | |||
| } | |||
| if rsp.Response.Code != "10000" { | |||
| return "", fmt.Errorf("支付宝错误: %s - %s", rsp.Response.Code, rsp.Response.Msg) | |||
| } | |||
| return rsp.Response.QrCode, nil | |||
| } | |||
| // RequestAlipayPayAmount 计算支付宝应付金额 | |||
| func RequestAlipayPayAmount(c *gin.Context) { | |||
| var req AlipayPayRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) | |||
| return | |||
| } | |||
| minTopup := calcMinTopup(setting.AlipayMinTopUp) | |||
| if req.Amount < minTopup { | |||
| c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)}) | |||
| return | |||
| } | |||
| id := c.GetInt("id") | |||
| group, err := model.GetUserGroup(id, true) | |||
| if err != nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) | |||
| return | |||
| } | |||
| payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice) | |||
| if payMoney <= 0.01 { | |||
| c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) | |||
| return | |||
| } | |||
| c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) | |||
| } | |||
| // RequestAlipayPay 创建支付宝支付订单,返回二维码 URL | |||
| func RequestAlipayPay(c *gin.Context) { | |||
| var req AlipayPayRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) | |||
| return | |||
| } | |||
| minTopup := calcMinTopup(setting.AlipayMinTopUp) | |||
| if req.Amount < minTopup { | |||
| c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)}) | |||
| return | |||
| } | |||
| if req.Amount > 10000 { | |||
| c.JSON(200, gin.H{"message": "error", "data": "充值数量不能大于 10000"}) | |||
| return | |||
| } | |||
| if !setting.IsAlipayConfigured() { | |||
| c.JSON(200, gin.H{"message": "error", "data": "支付宝未配置"}) | |||
| return | |||
| } | |||
| client, err := getAlipayClient() | |||
| if err != nil { | |||
| log.Println("获取支付宝客户端失败:", err) | |||
| c.JSON(200, gin.H{"message": "error", "data": "支付宝配置错误"}) | |||
| return | |||
| } | |||
| id := c.GetInt("id") | |||
| group, err := model.GetUserGroup(id, true) | |||
| if err != nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) | |||
| return | |||
| } | |||
| topupGroupRatio := common.GetTopupGroupRatio(group) | |||
| if topupGroupRatio == 0 { | |||
| topupGroupRatio = 1 | |||
| } | |||
| chargedMoney := float64(req.Amount) * topupGroupRatio | |||
| tradeNo := fmt.Sprintf("ali%d%s", time.Now().UnixMilli(), randstr.String(8)) | |||
| payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice) | |||
| totalAmount := strconv.FormatFloat(payMoney, 'f', 2, 64) | |||
| qrCode, err := createAlipayPrecreateOrder(client, fmt.Sprintf("充值%d", req.Amount), tradeNo, totalAmount) | |||
| if err != nil { | |||
| log.Println(err) | |||
| c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) | |||
| return | |||
| } | |||
| topUp := &model.TopUp{ | |||
| UserId: id, | |||
| Amount: req.Amount, | |||
| Money: chargedMoney, | |||
| TradeNo: tradeNo, | |||
| PaymentMethod: PaymentMethodAlipay, | |||
| CreateTime: time.Now().Unix(), | |||
| Status: common.TopUpStatusPending, | |||
| } | |||
| if err := topUp.Insert(); err != nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) | |||
| return | |||
| } | |||
| c.JSON(200, gin.H{ | |||
| "message": "success", | |||
| "data": gin.H{ | |||
| "trade_no": tradeNo, | |||
| "qr_code_url": qrCode, | |||
| }, | |||
| }) | |||
| } | |||
| // AlipayPayStatus 轮询支付宝支付订单状态 | |||
| func AlipayPayStatus(c *gin.Context) { | |||
| tradeNo := c.Query("trade_no") | |||
| if tradeNo == "" { | |||
| c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) | |||
| return | |||
| } | |||
| topUp := model.GetTopUpByTradeNo(tradeNo) | |||
| if topUp == nil { | |||
| c.JSON(200, gin.H{"message": "error", "data": "订单不存在"}) | |||
| return | |||
| } | |||
| userId := c.GetInt("id") | |||
| if topUp.UserId != userId { | |||
| c.JSON(200, gin.H{"message": "error", "data": "订单不存在"}) | |||
| return | |||
| } | |||
| c.JSON(200, gin.H{ | |||
| "message": "success", | |||
| "data": gin.H{ | |||
| "status": topUp.Status, | |||
| "amount": topUp.Amount, | |||
| }, | |||
| }) | |||
| } | |||
| // AlipayPayWebhook 处理支付宝异步回调通知 | |||
| func AlipayPayWebhook(c *gin.Context) { | |||
| notifyReq, err := alipay.ParseNotifyToBodyMap(c.Request) | |||
| if err != nil { | |||
| log.Printf("解析支付宝回调失败: %v", err) | |||
| c.String(http.StatusBadRequest, "fail") | |||
| return | |||
| } | |||
| ok, err := alipay.VerifySign(setting.AlipayPublicKey, notifyReq) | |||
| if err != nil { | |||
| log.Printf("支付宝回调验签失败: %v", err) | |||
| c.String(http.StatusBadRequest, "fail") | |||
| return | |||
| } | |||
| if !ok { | |||
| log.Printf("支付宝回调验签不通过") | |||
| c.String(http.StatusBadRequest, "fail") | |||
| return | |||
| } | |||
| tradeStatus := notifyReq.Get("trade_status") | |||
| if tradeStatus != "TRADE_SUCCESS" { | |||
| c.String(http.StatusOK, "success") | |||
| return | |||
| } | |||
| tradeNo := notifyReq.Get("out_trade_no") | |||
| LockOrder(tradeNo) | |||
| defer UnlockOrder(tradeNo) | |||
| if err := model.RechargeAlipay(tradeNo); err != nil { | |||
| log.Printf("支付宝充值失败: %s, err: %s", tradeNo, err.Error()) | |||
| c.String(http.StatusInternalServerError, "fail") | |||
| return | |||
| } | |||
| log.Printf("支付宝充值成功: %s", tradeNo) | |||
| c.String(http.StatusOK, "success") | |||
| } | |||
| @@ -18,7 +18,6 @@ import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting" | |||
| "github.com/QuantumNous/new-api/setting/operation_setting" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/go-pay/gopay" | |||
| @@ -355,31 +354,10 @@ func WechatPayWebhook(c *gin.Context) { | |||
| c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) | |||
| } | |||
| // getWechatPayMoney 计算微信支付应付金额(元) | |||
| func getWechatPayMoney(amount float64, group string) float64 { | |||
| originalAmount := amount | |||
| if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { | |||
| amount = amount / common.QuotaPerUnit | |||
| } | |||
| topupGroupRatio := common.GetTopupGroupRatio(group) | |||
| if topupGroupRatio == 0 { | |||
| topupGroupRatio = 1 | |||
| } | |||
| discount := 1.0 | |||
| if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { | |||
| if ds > 0 { | |||
| discount = ds | |||
| } | |||
| } | |||
| payMoney := amount * setting.WechatPayUnitPrice * topupGroupRatio * discount | |||
| return payMoney | |||
| return calcPayMoney(amount, group, setting.WechatPayUnitPrice) | |||
| } | |||
| // getWechatMinTopup 获取微信支付最低充值数量 | |||
| func getWechatMinTopup() int64 { | |||
| minTopup := setting.WechatPayMinTopUp | |||
| if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { | |||
| minTopup = minTopup * int(common.QuotaPerUnit) | |||
| } | |||
| return int64(minTopup) | |||
| return calcMinTopup(setting.WechatPayMinTopUp) | |||
| } | |||
| @@ -101,6 +101,12 @@ func InitOptionMap() { | |||
| common.OptionMap["WechatPayPubKeyB64"] = setting.WechatPayPubKeyB64 | |||
| common.OptionMap["WechatPayMinTopUp"] = strconv.Itoa(setting.WechatPayMinTopUp) | |||
| common.OptionMap["WechatPayUnitPrice"] = strconv.FormatFloat(setting.WechatPayUnitPrice, 'f', -1, 64) | |||
| common.OptionMap["AlipayAppID"] = setting.AlipayAppID | |||
| common.OptionMap["AlipayPrivateKey"] = setting.AlipayPrivateKey | |||
| common.OptionMap["AlipayPublicKey"] = setting.AlipayPublicKey | |||
| common.OptionMap["AlipayNotifyURL"] = setting.AlipayNotifyURL | |||
| common.OptionMap["AlipayMinTopUp"] = strconv.Itoa(setting.AlipayMinTopUp) | |||
| common.OptionMap["AlipayUnitPrice"] = strconv.FormatFloat(setting.AlipayUnitPrice, 'f', -1, 64) | |||
| common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() | |||
| common.OptionMap["Chats"] = setting.Chats2JsonString() | |||
| common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() | |||
| @@ -178,6 +184,13 @@ func triggerWechatPayReset() { | |||
| } | |||
| } | |||
| // triggerAlipayReset 安全触发支付宝客户端重置 | |||
| func triggerAlipayReset() { | |||
| if setting.OnAlipayConfigChanged != nil { | |||
| setting.OnAlipayConfigChanged() | |||
| } | |||
| } | |||
| func loadOptionsFromDatabase() { | |||
| options, _ := AllOption() | |||
| for _, option := range options { | |||
| @@ -410,6 +423,21 @@ func updateOptionMap(key string, value string) (err error) { | |||
| setting.WechatPayMinTopUp, _ = strconv.Atoi(value) | |||
| case "WechatPayUnitPrice": | |||
| setting.WechatPayUnitPrice, _ = strconv.ParseFloat(value, 64) | |||
| case "AlipayAppID": | |||
| setting.AlipayAppID = value | |||
| triggerAlipayReset() | |||
| case "AlipayPrivateKey": | |||
| setting.AlipayPrivateKey = value | |||
| triggerAlipayReset() | |||
| case "AlipayPublicKey": | |||
| setting.AlipayPublicKey = value | |||
| triggerAlipayReset() | |||
| case "AlipayNotifyURL": | |||
| setting.AlipayNotifyURL = value | |||
| case "AlipayMinTopUp": | |||
| setting.AlipayMinTopUp, _ = strconv.Atoi(value) | |||
| case "AlipayUnitPrice": | |||
| setting.AlipayUnitPrice, _ = strconv.ParseFloat(value, 64) | |||
| case "TopupGroupRatio": | |||
| err = common.UpdateTopupGroupRatioByJSONString(value) | |||
| case "GitHubClientId": | |||
| @@ -5,6 +5,7 @@ import ( | |||
| "fmt" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/QuantumNous/new-api/logger" | |||
| "github.com/shopspring/decimal" | |||
| @@ -21,6 +22,32 @@ type TopUp struct { | |||
| CreateTime int64 `json:"create_time"` | |||
| CompleteTime int64 `json:"complete_time"` | |||
| Status string `json:"status"` | |||
| UserEmail string `json:"user_email" gorm:"-"` // Join 查询时填充,非数据库字段 | |||
| } | |||
| // fillTopUpEmails 批量填充 topup 记录的用户邮箱 | |||
| func fillTopUpEmails(topups []*TopUp) { | |||
| if len(topups) == 0 { | |||
| return | |||
| } | |||
| userIds := types.NewSet[int]() | |||
| for _, t := range topups { | |||
| userIds.Add(t.UserId) | |||
| } | |||
| var users []User | |||
| if err := DB.Select("id, email").Where("id IN ?", userIds.Items()).Find(&users).Error; err != nil { | |||
| common.SysError("fillTopUpEmails: " + err.Error()) | |||
| return | |||
| } | |||
| emailMap := make(map[int]string, len(users)) | |||
| for _, u := range users { | |||
| emailMap[u.Id] = u.Email | |||
| } | |||
| for _, t := range topups { | |||
| t.UserEmail = emailMap[t.UserId] | |||
| } | |||
| } | |||
| func (topUp *TopUp) Insert() error { | |||
| @@ -135,6 +162,7 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota | |||
| return nil, 0, err | |||
| } | |||
| fillTopUpEmails(topups) | |||
| return topups, total, nil | |||
| } | |||
| @@ -164,6 +192,7 @@ func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err | |||
| return nil, 0, err | |||
| } | |||
| fillTopUpEmails(topups) | |||
| return topups, total, nil | |||
| } | |||
| @@ -198,6 +227,7 @@ func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (to | |||
| if err = tx.Commit().Error; err != nil { | |||
| return nil, 0, err | |||
| } | |||
| fillTopUpEmails(topups) | |||
| return topups, total, nil | |||
| } | |||
| @@ -232,6 +262,7 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp | |||
| if err = tx.Commit().Error; err != nil { | |||
| return nil, 0, err | |||
| } | |||
| fillTopUpEmails(topups) | |||
| return topups, total, nil | |||
| } | |||
| @@ -269,7 +300,7 @@ func ManualCompleteTopUp(tradeNo string) error { | |||
| // 计算应充值额度: | |||
| // - Stripe/微信支付订单:Money 代表经分组倍率换算后的数量,直接 * QuotaPerUnit | |||
| // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit | |||
| if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "wechat_pay" { | |||
| if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "wechat_pay" || topUp.PaymentMethod == "alipay" { | |||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | |||
| quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) | |||
| } else { | |||
| @@ -12,8 +12,18 @@ import ( | |||
| ) | |||
| // RechargeWechat 微信支付充值完成(由回调触发) | |||
| // 与 Recharge/RechargeCreem 类似,使用事务+行锁保证幂等 | |||
| func RechargeWechat(tradeNo string) error { | |||
| return rechargeByQRCodePayment(tradeNo, "微信支付") | |||
| } | |||
| // RechargeAlipay 支付宝充值完成(由回调触发) | |||
| func RechargeAlipay(tradeNo string) error { | |||
| return rechargeByQRCodePayment(tradeNo, "支付宝") | |||
| } | |||
| // rechargeByQRCodePayment 扫码支付充值完成(微信/支付宝通用) | |||
| // 使用事务+行锁保证幂等 | |||
| func rechargeByQRCodePayment(tradeNo string, paymentMethod string) error { | |||
| if tradeNo == "" { | |||
| return errors.New("未提供支付单号") | |||
| } | |||
| @@ -34,7 +44,6 @@ func RechargeWechat(tradeNo string) error { | |||
| } | |||
| if topUp.Status == common.TopUpStatusSuccess { | |||
| // 已处理,幂等返回 | |||
| return nil | |||
| } | |||
| @@ -48,9 +57,6 @@ func RechargeWechat(tradeNo string) error { | |||
| return err | |||
| } | |||
| // 微信支付充值额度计算: | |||
| // topUp.Money = req.Amount * topUpGroupRatio(经分组倍率调整后的数量) | |||
| // 充值额度 = topUp.Money * QuotaPerUnit(与 Stripe 的 Recharge 逻辑一致) | |||
| dMoney := decimal.NewFromFloat(topUp.Money) | |||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | |||
| quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart() | |||
| @@ -73,7 +79,7 @@ func RechargeWechat(tradeNo string) error { | |||
| } | |||
| if quotaToAdd > 0 { | |||
| RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用微信支付充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney)) | |||
| RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用%s充值成功,充值金额: %v,支付金额:%.2f", paymentMethod, logger.FormatQuota(int(quotaToAdd)), payMoney)) | |||
| } | |||
| return nil | |||
| @@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) { | |||
| apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | |||
| apiRouter.POST("/creem/webhook", controller.CreemWebhook) | |||
| apiRouter.POST("/wechat/pay/webhook", controller.WechatPayWebhook) | |||
| apiRouter.POST("/alipay/pay/webhook", controller.AlipayPayWebhook) | |||
| // Universal secure verification routes | |||
| apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) | |||
| @@ -93,6 +94,9 @@ func SetApiRouter(router *gin.Engine) { | |||
| selfRoute.POST("/wechat/pay/amount", controller.RequestWechatPayAmount) | |||
| selfRoute.POST("/wechat/pay", controller.RequestWechatPay) | |||
| selfRoute.GET("/wechat/pay/status", controller.WechatPayStatus) | |||
| selfRoute.POST("/alipay/pay/amount", controller.RequestAlipayPayAmount) | |||
| selfRoute.POST("/alipay/pay", controller.RequestAlipayPay) | |||
| selfRoute.GET("/alipay/pay/status", controller.AlipayPayStatus) | |||
| selfRoute.POST("/aff_transfer", controller.TransferAffQuota) | |||
| selfRoute.PUT("/setting", controller.UpdateUserSetting) | |||
| @@ -0,0 +1,18 @@ | |||
| package setting | |||
| var AlipayAppID = "" | |||
| var AlipayPrivateKey = "" // 应用私钥(RSA2) | |||
| var AlipayPublicKey = "" // 支付宝公钥(用于验签) | |||
| var AlipayNotifyURL = "" | |||
| var AlipayMinTopUp = 1 | |||
| var AlipayUnitPrice = 7.0 | |||
| // IsAlipayConfigured 检查支付宝核心配置是否完整 | |||
| func IsAlipayConfigured() bool { | |||
| return AlipayAppID != "" && | |||
| AlipayPrivateKey != "" && | |||
| AlipayPublicKey != "" | |||
| } | |||
| // OnAlipayConfigChanged 配置变更时调用的回调函数(由 controller 包注册) | |||
| var OnAlipayConfigChanged func() | |||
| @@ -490,7 +490,7 @@ const LoginForm = () => { | |||
| userDispatch({ type: 'login', payload: data }); | |||
| setUserData(data); | |||
| updateAPI(); | |||
| showSuccess('登录成功!'); | |||
| showSuccess(t('登录成功!')); | |||
| navigate('/console'); | |||
| }; | |||
| @@ -184,7 +184,7 @@ const RegisterForm = () => { | |||
| const onSubmitWeChatVerificationCode = async () => { | |||
| if (turnstileEnabled && turnstileToken === '') { | |||
| showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | |||
| showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); | |||
| return; | |||
| } | |||
| setWechatCodeSubmitLoading(true); | |||
| @@ -257,7 +257,7 @@ const RegisterForm = () => { | |||
| const sendVerificationCode = async () => { | |||
| if (inputs.email === '') return; | |||
| if (turnstileEnabled && turnstileToken === '') { | |||
| showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | |||
| showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); | |||
| return; | |||
| } | |||
| setVerificationCodeLoading(true); | |||
| @@ -745,7 +745,7 @@ const RegisterForm = () => { | |||
| }} | |||
| > | |||
| <div className='flex flex-col items-center'> | |||
| <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' /> | |||
| <img src={status.wechat_qrcode} alt={t('微信二维码')} className='mb-4' /> | |||
| </div> | |||
| <div className='text-center mb-4'> | |||
| @@ -268,7 +268,7 @@ export function PreCode(props) { | |||
| color: 'var(--semi-color-text-2)', | |||
| }} | |||
| > | |||
| HTML预览: | |||
| {t('HTML预览:')} | |||
| </div> | |||
| <SandboxedHtmlPreview code={htmlCode} /> | |||
| </div> | |||
| @@ -635,6 +635,7 @@ function _MarkdownContent(props) { | |||
| export const MarkdownContent = React.memo(_MarkdownContent); | |||
| export function MarkdownRenderer(props) { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| content, | |||
| loading, | |||
| @@ -680,7 +681,7 @@ export function MarkdownRenderer(props) { | |||
| animation: 'spin 1s linear infinite', | |||
| }} | |||
| /> | |||
| 正在渲染... | |||
| {t('正在渲染...')} | |||
| </div> | |||
| ) : ( | |||
| <MarkdownContent | |||
| @@ -661,7 +661,7 @@ const JSONEditor = ({ | |||
| {hasJsonError && ( | |||
| <Banner | |||
| type='danger' | |||
| description={`JSON 格式错误: ${jsonError}`} | |||
| description={`${t('JSON 格式错误')}: ${jsonError}`} | |||
| className='mb-3' | |||
| /> | |||
| )} | |||
| @@ -201,7 +201,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => { | |||
| } | |||
| return ( | |||
| formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + | |||
| '\n\n// ... 内容被截断以提升性能 ...' | |||
| '\n\n// ... ' + t('内容被截断以提升性能') + ' ...' | |||
| ); | |||
| }, [formattedContent, contentMetrics.isLarge, isExpanded]); | |||
| @@ -146,7 +146,7 @@ const DebugPanel = ({ | |||
| {t('预览请求体')} | |||
| {customRequestMode && ( | |||
| <span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'> | |||
| 自定义 | |||
| {t('自定义')} | |||
| </span> | |||
| )} | |||
| </div> | |||
| @@ -272,7 +272,7 @@ const MessageContent = ({ | |||
| <div key={index} className='max-w-sm'> | |||
| <img | |||
| src={imgItem.image_url.url} | |||
| alt={`用户上传的图片 ${index + 1}`} | |||
| alt={t('用户上传的图片', { index: index + 1 })} | |||
| className='rounded-lg max-w-full h-auto shadow-sm border' | |||
| style={{ maxHeight: '300px' }} | |||
| onError={(e) => { | |||
| @@ -284,7 +284,7 @@ const MessageContent = ({ | |||
| className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200' | |||
| style={{ display: 'none' }} | |||
| > | |||
| 图片加载失败: {imgItem.image_url.url} | |||
| {t('图片加载失败')}: {imgItem.image_url.url} | |||
| </div> | |||
| </div> | |||
| ))} | |||
| @@ -105,7 +105,7 @@ const ThinkingContent = ({ | |||
| style={{ color: 'white' }} | |||
| className='text-xs mt-0.5 opacity-80 hidden sm:block' | |||
| > | |||
| 来源: {thinkingSource} | |||
| {t('来源')}: {thinkingSource} | |||
| </Typography.Text> | |||
| )} | |||
| </div> | |||
| @@ -122,7 +122,7 @@ const ThinkingContent = ({ | |||
| style={{ color: 'white' }} | |||
| className='text-xs sm:text-sm font-medium opacity-90' | |||
| > | |||
| 思考中 | |||
| {t('思考中')} | |||
| </Typography.Text> | |||
| </div> | |||
| )} | |||
| @@ -21,6 +21,7 @@ import { | |||
| STORAGE_KEYS, | |||
| DEFAULT_CONFIG, | |||
| } from '../../constants/playground.constants'; | |||
| import i18next from 'i18next'; | |||
| const MESSAGES_STORAGE_KEY = 'playground_messages'; | |||
| @@ -215,16 +216,16 @@ export const importConfig = (file) => { | |||
| resolve(importedConfig); | |||
| } else { | |||
| reject(new Error('配置文件格式无效')); | |||
| reject(new Error(i18next.t('配置文件格式无效'))); | |||
| } | |||
| } catch (parseError) { | |||
| reject(new Error('解析配置文件失败: ' + parseError.message)); | |||
| reject(new Error(i18next.t('解析配置文件失败: ') + parseError.message)); | |||
| } | |||
| }; | |||
| reader.onerror = () => reject(new Error('读取文件失败')); | |||
| reader.onerror = () => reject(new Error(i18next.t('读取文件失败'))); | |||
| reader.readAsText(file); | |||
| } catch (error) { | |||
| reject(new Error('导入配置失败: ' + error.message)); | |||
| reject(new Error(i18next.t('导入配置失败: ') + error.message)); | |||
| } | |||
| }); | |||
| }; | |||
| @@ -60,7 +60,7 @@ const ModelDeploymentSetting = () => { | |||
| setLoading(true); | |||
| await getOptions(); | |||
| } catch (error) { | |||
| showError('刷新失败'); | |||
| showError(t('刷新失败')); | |||
| console.error(error); | |||
| } finally { | |||
| setLoading(false); | |||
| @@ -95,7 +95,7 @@ const ModelSetting = () => { | |||
| await getOptions(); | |||
| // showSuccess('刷新成功'); | |||
| } catch (error) { | |||
| showError('刷新失败'); | |||
| showError(t('刷新失败')); | |||
| console.error(error); | |||
| } finally { | |||
| setLoading(false); | |||
| @@ -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 SettingsPaymentGatewayWechat from '../../pages/Setting/Payment/SettingsPaymentGatewayWechat'; | |||
| import SettingsPaymentGatewayAlipay from '../../pages/Setting/Payment/SettingsPaymentGatewayAlipay'; | |||
| import { API, showError, toBoolean } from '../../helpers'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| @@ -101,6 +102,8 @@ const PaymentSetting = () => { | |||
| case 'StripeMinTopUp': | |||
| case 'WechatPayUnitPrice': | |||
| case 'WechatPayMinTopUp': | |||
| case 'AlipayUnitPrice': | |||
| case 'AlipayMinTopUp': | |||
| newInputs[item.key] = parseFloat(item.value); | |||
| break; | |||
| default: | |||
| @@ -152,6 +155,9 @@ const PaymentSetting = () => { | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPaymentGatewayWechat options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPaymentGatewayAlipay options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| </Spin> | |||
| </> | |||
| ); | |||
| @@ -64,7 +64,7 @@ const RateLimitSetting = () => { | |||
| await getOptions(); | |||
| // showSuccess('刷新成功'); | |||
| } catch (error) { | |||
| showError('刷新失败'); | |||
| showError(t('刷新失败')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -83,7 +83,7 @@ const RatioSetting = () => { | |||
| setLoading(true); | |||
| await getOptions(); | |||
| } catch (error) { | |||
| showError('刷新失败'); | |||
| showError(t('刷新失败')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -533,7 +533,7 @@ const NotificationSettings = ({ | |||
| <CodeViewer | |||
| content={{ | |||
| type: 'quota_exceed', | |||
| title: '额度预警通知', | |||
| title: t('额度预警通知'), | |||
| content: | |||
| '您的额度即将用尽,当前剩余额度为 {{value}}', | |||
| values: ['$0.99'], | |||
| @@ -74,7 +74,7 @@ const renderType = (type, record = {}, t) => { | |||
| const typeTag = ( | |||
| <Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}> | |||
| {type2label[type]?.label} | |||
| {t(type2label[type]?.label)} | |||
| </Tag> | |||
| ); | |||
| @@ -78,7 +78,7 @@ const ChannelsTabs = ({ | |||
| tab={ | |||
| <span className='flex items-center gap-2'> | |||
| {getChannelIcon(option.value)} | |||
| {option.label} | |||
| {t(option.label)} | |||
| <Tag | |||
| color={activeTypeKey === key ? 'red' : 'grey'} | |||
| shape='circle' | |||
| @@ -467,7 +467,7 @@ const EditChannelModal = (props) => { | |||
| if (name === 'base_url' && value.endsWith('/v1')) { | |||
| Modal.confirm({ | |||
| title: '警告', | |||
| title: t('警告'), | |||
| content: | |||
| '不需要在末尾加/v1,New API会自动处理,添加后可能导致请求失败,是否继续?', | |||
| onOk: () => { | |||
| @@ -1725,10 +1725,10 @@ const EditChannelModal = (props) => { | |||
| () => | |||
| CHANNEL_OPTIONS.map((opt) => ({ | |||
| ...opt, | |||
| // 保持 label 为纯文本以支持搜索 | |||
| label: opt.label, | |||
| // 使用 t() 翻译 label,同时保留原始中文 key 以支持搜索 | |||
| label: t(opt.label), | |||
| })), | |||
| [], | |||
| [t], | |||
| ); | |||
| const renderChannelOption = (renderProps) => { | |||
| @@ -236,7 +236,7 @@ const EditTagModal = (props) => { | |||
| data.param_override === undefined && | |||
| data.header_override === undefined | |||
| ) { | |||
| showWarning('没有任何修改!'); | |||
| showWarning(t('没有任何修改!')); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| @@ -248,7 +248,7 @@ const EditTagModal = (props) => { | |||
| try { | |||
| const res = await API.put('/api/channel/tag', data); | |||
| if (res?.data?.success) { | |||
| showSuccess('标签更新成功!'); | |||
| showSuccess(t('标签更新成功!')); | |||
| refresh(); | |||
| handleClose(); | |||
| } | |||
| @@ -150,7 +150,7 @@ const renderPlatform = (platform, t) => { | |||
| if (option) { | |||
| return ( | |||
| <Tag color={option.color} shape='circle'> | |||
| {option.label} | |||
| {t(option.label)} | |||
| </Tag> | |||
| ); | |||
| } | |||
| @@ -8,7 +8,7 @@ const { Text } = Typography; | |||
| const POLL_INTERVAL = 2000; | |||
| export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onClose, onSuccess }) { | |||
| export default function QRCodePayModal({ visible, qrCodeUrl, tradeNo, onClose, onSuccess, title, subtitle, statusApiPath }) { | |||
| const { t } = useTranslation(); | |||
| const [status, setStatus] = useState('pending'); | |||
| const [countdown, setCountdown] = useState(300); | |||
| @@ -18,7 +18,7 @@ export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onCl | |||
| const pollStatus = useCallback(async () => { | |||
| if (!tradeNo) return; | |||
| try { | |||
| const res = await API.get(`/api/user/wechat/pay/status?trade_no=${tradeNo}`); | |||
| const res = await API.get(`${statusApiPath}?trade_no=${tradeNo}`); | |||
| const { data } = res.data; | |||
| if (data?.status === 'success') { | |||
| setStatus('success'); | |||
| @@ -35,7 +35,7 @@ export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onCl | |||
| } catch (e) { | |||
| // 忽略轮询错误 | |||
| } | |||
| }, [tradeNo, onSuccess]); | |||
| }, [tradeNo, onSuccess, statusApiPath]); | |||
| useEffect(() => { | |||
| if (visible && tradeNo && status === 'pending') { | |||
| @@ -108,7 +108,7 @@ export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onCl | |||
| </div> | |||
| <div style={{ marginTop: 16 }}> | |||
| <Text type="secondary"> | |||
| {t('请使用微信扫描二维码完成支付')} | |||
| {subtitle || t('请扫描二维码完成支付')} | |||
| </Text> | |||
| </div> | |||
| <div style={{ marginTop: 8 }}> | |||
| @@ -123,7 +123,7 @@ export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onCl | |||
| return ( | |||
| <Modal | |||
| title={t('微信支付')} | |||
| title={title || t('扫码支付')} | |||
| visible={visible} | |||
| onCancel={onClose} | |||
| footer={null} | |||
| @@ -58,6 +58,7 @@ const RechargeCard = ({ | |||
| enableStripeTopUp, | |||
| enableCreemTopUp, | |||
| enableWechatTopUp, | |||
| enableAlipayTopUp, | |||
| creemProducts, | |||
| creemPreTopUp, | |||
| presetAmounts, | |||
| @@ -225,19 +226,19 @@ const RechargeCard = ({ | |||
| <div className='py-8 flex justify-center'> | |||
| <Spin size='large' /> | |||
| </div> | |||
| ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWechatTopUp ? ( | |||
| ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWechatTopUp || enableAlipayTopUp ? ( | |||
| <Form | |||
| getFormApi={(api) => (onlineFormApiRef.current = api)} | |||
| initValues={{ topUpCount: topUpCount }} | |||
| > | |||
| <div className='space-y-6'> | |||
| {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && ( | |||
| {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp || enableAlipayTopUp) && ( | |||
| <Row gutter={12}> | |||
| <Col xs={24} sm={24} md={24} lg={10} xl={10}> | |||
| <Form.InputNumber | |||
| field='topUpCount' | |||
| label={t('充值数量')} | |||
| disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWechatTopUp} | |||
| disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWechatTopUp && !enableAlipayTopUp} | |||
| placeholder={ | |||
| t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp) | |||
| } | |||
| @@ -297,11 +298,13 @@ const RechargeCard = ({ | |||
| const minTopupVal = Number(payMethod.min_topup) || 0; | |||
| const isStripe = payMethod.type === 'stripe'; | |||
| const isWechatPay = payMethod.type === 'wechat_pay'; | |||
| const isEpay = !isStripe && !isWechatPay; | |||
| const isAlipay = payMethod.type === 'alipay'; | |||
| const isEpay = !isStripe && !isWechatPay && !isAlipay; | |||
| const disabled = | |||
| (!enableOnlineTopUp && isEpay) || | |||
| (!enableStripeTopUp && isStripe) || | |||
| (!enableWechatTopUp && isWechatPay) || | |||
| (!enableAlipayTopUp && isAlipay) || | |||
| minTopupVal > Number(topUpCount || 0); | |||
| const buttonEl = ( | |||
| @@ -366,7 +369,7 @@ const RechargeCard = ({ | |||
| </Row> | |||
| )} | |||
| {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && ( | |||
| {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp || enableAlipayTopUp) && ( | |||
| <Form.Slot | |||
| label={ | |||
| <div className='flex items-center gap-2'> | |||
| @@ -38,7 +38,7 @@ import InvitationCard from './InvitationCard'; | |||
| import TransferModal from './modals/TransferModal'; | |||
| import PaymentConfirmModal from './modals/PaymentConfirmModal'; | |||
| import TopupHistoryModal from './modals/TopupHistoryModal'; | |||
| import WechatPayQRCodeModal from './WechatPayQRCodeModal'; | |||
| import QRCodePayModal from './QRCodePayModal'; | |||
| const TopUp = () => { | |||
| const { t } = useTranslation(); | |||
| @@ -94,6 +94,12 @@ const TopUp = () => { | |||
| const [wechatPayTradeNo, setWechatPayTradeNo] = useState(''); | |||
| const [enableWechatTopUp, setEnableWechatTopUp] = useState(false); | |||
| // 支付宝支付相关状态 | |||
| const [alipayPayVisible, setAlipayPayVisible] = useState(false); | |||
| const [alipayPayQRCodeUrl, setAlipayPayQRCodeUrl] = useState(''); | |||
| const [alipayPayTradeNo, setAlipayPayTradeNo] = useState(''); | |||
| const [enableAlipayTopUp, setEnableAlipayTopUp] = useState(false); | |||
| // 订阅相关 | |||
| const [subscriptionPlans, setSubscriptionPlans] = useState([]); | |||
| const [subscriptionLoading, setSubscriptionLoading] = useState(true); | |||
| @@ -167,6 +173,11 @@ const TopUp = () => { | |||
| showError(t('管理员未开启微信支付充值!')); | |||
| return; | |||
| } | |||
| } else if (payment === 'alipay') { | |||
| if (!enableAlipayTopUp) { | |||
| showError(t('管理员未开启支付宝充值!')); | |||
| return; | |||
| } | |||
| } else { | |||
| if (!enableOnlineTopUp) { | |||
| showError(t('管理员未开启在线充值!')); | |||
| @@ -200,6 +211,30 @@ const TopUp = () => { | |||
| return; | |||
| } | |||
| // 支付宝支付直接创建订单并显示二维码 | |||
| if (payment === 'alipay') { | |||
| setPaymentLoading(true); | |||
| try { | |||
| const res = await API.post('/api/user/alipay/pay', { | |||
| amount: parseInt(topUpCount), | |||
| }); | |||
| const { message, data } = res.data; | |||
| if (message === 'success') { | |||
| setAlipayPayQRCodeUrl(data.qr_code_url); | |||
| setAlipayPayTradeNo(data.trade_no); | |||
| setAlipayPayVisible(true); | |||
| } else { | |||
| const errorMsg = typeof data === 'string' ? data : message || t('支付失败'); | |||
| showError(errorMsg); | |||
| } | |||
| } catch (err) { | |||
| showError(t('支付请求失败')); | |||
| } finally { | |||
| setPaymentLoading(false); | |||
| } | |||
| return; | |||
| } | |||
| setPaymentLoading(true); | |||
| try { | |||
| if (payment === 'stripe') { | |||
| @@ -483,17 +518,21 @@ const TopUp = () => { | |||
| const enableOnlineTopUp = data.enable_online_topup || false; | |||
| const enableCreemTopUp = data.enable_creem_topup || false; | |||
| const enableWechatTopUpVal = data.enable_wechat_topup || false; | |||
| const enableAlipayTopUpVal = data.enable_alipay_topup || false; | |||
| const minTopUpValue = enableOnlineTopUp | |||
| ? data.min_topup | |||
| : enableStripeTopUp | |||
| ? data.stripe_min_topup | |||
| : enableWechatTopUpVal | |||
| ? data.wechat_pay_min_topup || 1 | |||
| : 1; | |||
| : enableAlipayTopUpVal | |||
| ? data.alipay_pay_min_topup || 1 | |||
| : 1; | |||
| setEnableOnlineTopUp(enableOnlineTopUp); | |||
| setEnableStripeTopUp(enableStripeTopUp); | |||
| setEnableCreemTopUp(enableCreemTopUp); | |||
| setEnableWechatTopUp(enableWechatTopUpVal); | |||
| setEnableAlipayTopUp(enableAlipayTopUpVal); | |||
| setMinTopUp(minTopUpValue); | |||
| setTopUpCount(minTopUpValue); | |||
| @@ -622,7 +661,7 @@ const TopUp = () => { | |||
| setAmount(parseFloat(data)); | |||
| } else { | |||
| setAmount(0); | |||
| Toast.error({ content: '错误:' + data, id: 'getAmount' }); | |||
| Toast.error({ content: t('错误:') + data, id: 'getAmount' }); | |||
| } | |||
| } else { | |||
| showError(res); | |||
| @@ -648,7 +687,7 @@ const TopUp = () => { | |||
| setAmount(parseFloat(data)); | |||
| } else { | |||
| setAmount(0); | |||
| Toast.error({ content: '错误:' + data, id: 'getAmount' }); | |||
| Toast.error({ content: t('错误:') + data, id: 'getAmount' }); | |||
| } | |||
| } else { | |||
| showError(res); | |||
| @@ -745,7 +784,7 @@ const TopUp = () => { | |||
| /> | |||
| {/* 微信支付二维码弹窗 */} | |||
| <WechatPayQRCodeModal | |||
| <QRCodePayModal | |||
| visible={wechatPayVisible} | |||
| qrCodeUrl={wechatPayQRCodeUrl} | |||
| tradeNo={wechatPayTradeNo} | |||
| @@ -753,8 +792,27 @@ const TopUp = () => { | |||
| onSuccess={() => { | |||
| setWechatPayVisible(false); | |||
| showSuccess(t('充值成功!')); | |||
| userDispatch({ type: 'refresh' }); | |||
| getUserQuota(); | |||
| }} | |||
| title={t('微信支付')} | |||
| subtitle={t('请使用微信扫描二维码完成支付')} | |||
| statusApiPath="/api/user/wechat/pay/status" | |||
| /> | |||
| {/* 支付宝二维码弹窗 */} | |||
| <QRCodePayModal | |||
| visible={alipayPayVisible} | |||
| qrCodeUrl={alipayPayQRCodeUrl} | |||
| tradeNo={alipayPayTradeNo} | |||
| onClose={() => setAlipayPayVisible(false)} | |||
| onSuccess={() => { | |||
| setAlipayPayVisible(false); | |||
| showSuccess(t('充值成功!')); | |||
| getUserQuota(); | |||
| }} | |||
| title={t('支付宝')} | |||
| subtitle={t('请使用支付宝扫描二维码完成支付')} | |||
| statusApiPath="/api/user/alipay/pay/status" | |||
| /> | |||
| {/* Creem 充值确认模态框 */} | |||
| @@ -793,6 +851,7 @@ const TopUp = () => { | |||
| enableStripeTopUp={enableStripeTopUp} | |||
| enableCreemTopUp={enableCreemTopUp} | |||
| enableWechatTopUp={enableWechatTopUp} | |||
| enableAlipayTopUp={enableAlipayTopUp} | |||
| creemProducts={creemProducts} | |||
| creemPreTopUp={creemPreTopUp} | |||
| presetAmounts={presetAmounts} | |||
| @@ -168,6 +168,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { | |||
| key: 'trade_no', | |||
| render: (text) => <Text copyable>{text}</Text>, | |||
| }, | |||
| ]; | |||
| if (userIsAdmin) { | |||
| baseColumns.push({ | |||
| title: t('用户'), | |||
| dataIndex: 'user_email', | |||
| key: 'user_email', | |||
| render: (text) => <Text>{text || '-'}</Text>, | |||
| }); | |||
| } | |||
| baseColumns.push( | |||
| { | |||
| title: t('支付方式'), | |||
| dataIndex: 'payment_method', | |||
| @@ -206,7 +218,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { | |||
| key: 'status', | |||
| render: renderStatusBadge, | |||
| }, | |||
| ]; | |||
| ); | |||
| // 管理员才显示操作列 | |||
| if (userIsAdmin) { | |||
| @@ -24,6 +24,7 @@ import { | |||
| isValidMessage, | |||
| } from './utils'; | |||
| import axios from 'axios'; | |||
| import i18next from 'i18next'; | |||
| import { MESSAGE_ROLES } from '../constants/playground.constants'; | |||
| export let API = axios.create({ | |||
| @@ -147,7 +148,7 @@ export const buildApiPayload = ( | |||
| // 处理API错误响应 | |||
| export const handleApiError = (error, response = null) => { | |||
| const errorInfo = { | |||
| error: error.message || '未知错误', | |||
| error: error.message || i18next.t('未知错误'), | |||
| timestamp: new Date().toISOString(), | |||
| stack: error.stack, | |||
| }; | |||
| @@ -158,9 +159,9 @@ export const handleApiError = (error, response = null) => { | |||
| } | |||
| if (error.message.includes('HTTP error')) { | |||
| errorInfo.details = '服务器返回了错误状态码'; | |||
| errorInfo.details = i18next.t('服务器返回了错误状态码'); | |||
| } else if (error.message.includes('Failed to fetch')) { | |||
| errorInfo.details = '网络连接失败或服务器无响应'; | |||
| errorInfo.details = i18next.t('网络连接失败或服务器无响应'); | |||
| } | |||
| return errorInfo; | |||
| @@ -197,7 +198,7 @@ export const processGroupsData = (data, userGroup) => { | |||
| if (groupOptions.length === 0) { | |||
| groupOptions = [ | |||
| { | |||
| label: '用户分组', | |||
| label: i18next.t('用户分组'), | |||
| value: '', | |||
| ratio: 1, | |||
| }, | |||
| @@ -325,7 +326,7 @@ export async function onCustomOAuthClicked(provider, options = {}) { | |||
| provider.authorization_endpoint, | |||
| ); | |||
| showError( | |||
| 'OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)', | |||
| i18next.t('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)'), | |||
| ); | |||
| return; | |||
| } | |||
| @@ -342,7 +343,7 @@ export async function onCustomOAuthClicked(provider, options = {}) { | |||
| window.open(authUrl.toString()); | |||
| } catch (error) { | |||
| console.error('Failed to initiate custom OAuth:', error); | |||
| showError('OAuth 登录失败:' + (error.message || '未知错误')); | |||
| showError(i18next.t('OAuth 登录失败:') + (error.message || i18next.t('未知错误'))); | |||
| } | |||
| } | |||
| @@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import i18next from 'i18next'; | |||
| export function base64UrlToBuffer(base64url) { | |||
| if (!base64url) return new ArrayBuffer(0); | |||
| let padding = '='.repeat((4 - (base64url.length % 4)) % 4); | |||
| @@ -50,7 +52,7 @@ export function prepareCredentialCreationOptions(payload) { | |||
| payload?.response || | |||
| payload?.Response; | |||
| if (!options) { | |||
| throw new Error('无法从服务端响应中解析 Passkey 注册参数'); | |||
| throw new Error(i18next.t('无法从服务端响应中解析 Passkey 注册参数')); | |||
| } | |||
| const publicKey = { | |||
| ...options, | |||
| @@ -85,7 +87,7 @@ export function prepareCredentialRequestOptions(payload) { | |||
| payload?.response || | |||
| payload?.Response; | |||
| if (!options) { | |||
| throw new Error('无法从服务端响应中解析 Passkey 登录参数'); | |||
| throw new Error(i18next.t('无法从服务端响应中解析 Passkey 登录参数')); | |||
| } | |||
| const publicKey = { | |||
| ...options, | |||
| @@ -1373,7 +1373,7 @@ export function renderModelPrice( | |||
| symbol: symbol, | |||
| price: (inputRatioPrice * rate).toFixed(6), | |||
| audioPrice: audioInputSeperatePrice | |||
| ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens` | |||
| ? i18next.t(',音频 {{symbol}}{{price}} / 1M tokens', { symbol, price: (audioInputPrice * rate).toFixed(6) }) | |||
| : '', | |||
| }, | |||
| )} | |||
| @@ -249,7 +249,7 @@ export const useChannelsData = () => { | |||
| key: tag, | |||
| id: tag, | |||
| tag: tag, | |||
| name: '标签:' + tag, | |||
| name: t('标签:') + tag, | |||
| group: '', | |||
| used_quota: 0, | |||
| response_time: 0, | |||
| @@ -613,7 +613,7 @@ export const useChannelsData = () => { | |||
| switch (type) { | |||
| case 'priority': | |||
| if (data.priority === undefined || data.priority === '') { | |||
| showInfo('优先级必须是整数!'); | |||
| showInfo(t('优先级必须是整数!')); | |||
| return; | |||
| } | |||
| data.priority = parseInt(data.priority); | |||
| @@ -624,7 +624,7 @@ export const useChannelsData = () => { | |||
| data.weight < 0 || | |||
| data.weight === '' | |||
| ) { | |||
| showInfo('权重必须是非负整数!'); | |||
| showInfo(t('权重必须是非负整数!')); | |||
| return; | |||
| } | |||
| data.weight = parseInt(data.weight); | |||
| @@ -634,7 +634,7 @@ export const useChannelsData = () => { | |||
| try { | |||
| const res = await API.put('/api/channel/tag', data); | |||
| if (res?.data?.success) { | |||
| showSuccess('更新成功!'); | |||
| showSuccess(t('更新成功!')); | |||
| await refresh(); | |||
| } | |||
| } catch (error) { | |||
| @@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com | |||
| import { useEffect, useState } from 'react'; | |||
| import { fetchTokenKeys, getServerAddress } from '../../helpers/token'; | |||
| import { showError } from '../../helpers'; | |||
| import i18next from 'i18next'; | |||
| export function useTokenKeys(id) { | |||
| const [keys, setKeys] = useState([]); | |||
| @@ -30,7 +31,7 @@ export function useTokenKeys(id) { | |||
| const loadAllData = async () => { | |||
| const fetchedKeys = await fetchTokenKeys(); | |||
| if (fetchedKeys.length === 0) { | |||
| showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!'); | |||
| showError(i18next.t('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!')); | |||
| setTimeout(() => { | |||
| window.location.href = '/console/token'; | |||
| }, 1500); // 延迟 1.5 秒后跳转 | |||
| @@ -177,7 +177,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { | |||
| if (data.length === 0) { | |||
| data.push({ | |||
| count: 0, | |||
| model_name: '无数据', | |||
| model_name: t('无数据'), | |||
| quota: 0, | |||
| created_at: now.getTime() / 1000, | |||
| }); | |||
| @@ -52,22 +52,22 @@ const PartnersSection = () => { | |||
| { icon: Moonshot, name: 'Moonshot AI', size: 40 }, | |||
| { icon: OpenAI, name: 'OpenAI', size: 40 }, | |||
| { icon: XAI, name: 'Grok', size: 40 }, | |||
| { icon: Zhipu, name: '智谱', size: 40 }, | |||
| { icon: Volcengine, name: '火山引擎', size: 40 }, | |||
| { icon: Zhipu, name: t('智谱'), size: 40 }, | |||
| { icon: Volcengine, name: t('火山引擎'), size: 40 }, | |||
| { icon: Cohere, name: 'Cohere', size: 40 }, | |||
| { icon: Claude, name: 'Claude', size: 40 }, | |||
| { icon: Gemini, name: 'Gemini', size: 40 }, | |||
| { icon: Suno, name: 'Suno', size: 40 }, | |||
| { icon: Minimax, name: 'Minimax', size: 40 }, | |||
| { icon: Wenxin, name: '文心', size: 40 }, | |||
| { icon: Spark, name: '讯飞星火', size: 40 }, | |||
| { icon: Qingyan, name: '腾讯混元', size: 40 }, | |||
| { icon: Wenxin, name: t('文心'), size: 40 }, | |||
| { icon: Spark, name: t('讯飞星火'), size: 40 }, | |||
| { icon: Qingyan, name: t('腾讯混元'), size: 40 }, | |||
| { icon: DeepSeek, name: 'DeepSeek', size: 40 }, | |||
| { icon: Qwen, name: '通义千问', size: 40 }, | |||
| { icon: Qwen, name: t('通义千问'), size: 40 }, | |||
| { icon: Midjourney, name: 'Midjourney', size: 40 }, | |||
| { icon: Grok, name: 'Grok', size: 40 }, | |||
| { icon: AzureAI, name: 'Azure AI', size: 40 }, | |||
| { icon: Hunyuan, name: '腾讯混元', size: 40 }, | |||
| { icon: Hunyuan, name: t('腾讯混元'), size: 40 }, | |||
| { icon: Xinference, name: 'Xinference', size: 40 }, | |||
| ]; | |||
| @@ -214,7 +214,7 @@ const Playground = () => { | |||
| (url) => url.trim() !== '', | |||
| ); | |||
| if (validImageUrls.length > 0) { | |||
| const textContent = getTextContent(messages[i]) || '示例消息'; | |||
| const textContent = getTextContent(messages[i]) || t('示例消息'); | |||
| const content = buildMessageContent( | |||
| textContent, | |||
| validImageUrls, | |||
| @@ -262,7 +262,7 @@ const Playground = () => { | |||
| return; | |||
| } catch (error) { | |||
| console.error('自定义请求体JSON解析失败:', error); | |||
| Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); | |||
| Toast.error(t(ERROR_MESSAGES.JSON_PARSE_ERROR)); | |||
| return; | |||
| } | |||
| } | |||
| @@ -91,7 +91,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| }); | |||
| const { success, message } = res.data; | |||
| if (success) { | |||
| showSuccess('API信息已更新'); | |||
| showSuccess(t('API信息已更新')); | |||
| if (refresh) refresh(); | |||
| } else { | |||
| showError(message); | |||
| @@ -106,7 +106,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| setHasChanges(false); | |||
| } catch (error) { | |||
| console.error('API信息更新失败', error); | |||
| showError('API信息更新失败'); | |||
| showError(t('API信息更新失败')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -144,7 +144,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| const newList = apiInfoList.filter((api) => api.id !== deletingApi.id); | |||
| setApiInfoList(newList); | |||
| setHasChanges(true); | |||
| showSuccess('API信息已删除,请及时点击“保存设置”进行保存'); | |||
| showSuccess(t('API信息已删除,请及时点击”保存设置”进行保存')); | |||
| } | |||
| setShowDeleteModal(false); | |||
| setDeletingApi(null); | |||
| @@ -152,7 +152,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| const handleSaveApi = async () => { | |||
| if (!apiForm.url || !apiForm.route || !apiForm.description) { | |||
| showError('请填写完整的API信息'); | |||
| showError(t('请填写完整的API信息')); | |||
| return; | |||
| } | |||
| @@ -182,7 +182,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| : 'API信息已添加,请及时点击“保存设置”进行保存', | |||
| ); | |||
| } catch (error) { | |||
| showError('操作失败: ' + error.message); | |||
| showError(t('操作失败') + ': ' + error.message); | |||
| } finally { | |||
| setModalLoading(false); | |||
| } | |||
| @@ -299,7 +299,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { | |||
| const handleBatchDelete = () => { | |||
| if (selectedRowKeys.length === 0) { | |||
| showError('请先选择要删除的API信息'); | |||
| showError(t('请先选择要删除的API信息')); | |||
| return; | |||
| } | |||
| @@ -204,7 +204,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| }); | |||
| const { success, message } = res.data; | |||
| if (success) { | |||
| showSuccess('系统公告已更新'); | |||
| showSuccess(t('系统公告已更新')); | |||
| if (refresh) refresh(); | |||
| } else { | |||
| showError(message); | |||
| @@ -219,7 +219,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| setHasChanges(false); | |||
| } catch (error) { | |||
| console.error('系统公告更新失败', error); | |||
| showError('系统公告更新失败'); | |||
| showError(t('系统公告更新失败')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -261,7 +261,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| ); | |||
| setAnnouncementsList(newList); | |||
| setHasChanges(true); | |||
| showSuccess('公告已删除,请及时点击“保存设置”进行保存'); | |||
| showSuccess(t('公告已删除,请及时点击”保存设置”进行保存')); | |||
| } | |||
| setShowDeleteModal(false); | |||
| setDeletingAnnouncement(null); | |||
| @@ -269,7 +269,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| const handleSaveAnnouncement = async () => { | |||
| if (!announcementForm.content || !announcementForm.publishDate) { | |||
| showError('请填写完整的公告信息'); | |||
| showError(t('请填写完整的公告信息')); | |||
| return; | |||
| } | |||
| @@ -306,7 +306,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| : '公告已添加,请及时点击“保存设置”进行保存', | |||
| ); | |||
| } catch (error) { | |||
| showError('操作失败: ' + error.message); | |||
| showError(t('操作失败') + ': ' + error.message); | |||
| } finally { | |||
| setModalLoading(false); | |||
| } | |||
| @@ -371,7 +371,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { | |||
| const handleBatchDelete = () => { | |||
| if (selectedRowKeys.length === 0) { | |||
| showError('请先选择要删除的系统公告'); | |||
| showError(t('请先选择要删除的系统公告')); | |||
| return; | |||
| } | |||
| @@ -140,7 +140,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| }); | |||
| const { success, message } = res.data; | |||
| if (success) { | |||
| showSuccess('常见问答已更新'); | |||
| showSuccess(t('常见问答已更新')); | |||
| if (refresh) refresh(); | |||
| } else { | |||
| showError(message); | |||
| @@ -155,7 +155,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| setHasChanges(false); | |||
| } catch (error) { | |||
| console.error('常见问答更新失败', error); | |||
| showError('常见问答更新失败'); | |||
| showError(t('常见问答更新失败')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -189,7 +189,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| const newList = faqList.filter((item) => item.id !== deletingFaq.id); | |||
| setFaqList(newList); | |||
| setHasChanges(true); | |||
| showSuccess('问答已删除,请及时点击“保存设置”进行保存'); | |||
| showSuccess(t('问答已删除,请及时点击”保存设置”进行保存')); | |||
| } | |||
| setShowDeleteModal(false); | |||
| setDeletingFaq(null); | |||
| @@ -197,7 +197,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| const handleSaveFaq = async () => { | |||
| if (!faqForm.question || !faqForm.answer) { | |||
| showError('请填写完整的问答信息'); | |||
| showError(t('请填写完整的问答信息')); | |||
| return; | |||
| } | |||
| @@ -227,7 +227,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| : '问答已添加,请及时点击“保存设置”进行保存', | |||
| ); | |||
| } catch (error) { | |||
| showError('操作失败: ' + error.message); | |||
| showError(t('操作失败') + ': ' + error.message); | |||
| } finally { | |||
| setModalLoading(false); | |||
| } | |||
| @@ -290,7 +290,7 @@ const SettingsFAQ = ({ options, refresh }) => { | |||
| const handleBatchDelete = () => { | |||
| if (selectedRowKeys.length === 0) { | |||
| showError('请先选择要删除的常见问答'); | |||
| showError(t('请先选择要删除的常见问答')); | |||
| return; | |||
| } | |||
| @@ -0,0 +1,210 @@ | |||
| import React, { useEffect, useState, useRef } from 'react'; | |||
| import { | |||
| Banner, | |||
| Button, | |||
| Form, | |||
| Row, | |||
| Col, | |||
| Typography, | |||
| Spin, | |||
| } from '@douyinfe/semi-ui'; | |||
| const { Text } = Typography; | |||
| import { | |||
| API, | |||
| removeTrailingSlash, | |||
| showError, | |||
| showSuccess, | |||
| } from '../../../helpers'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export default function SettingsPaymentGatewayAlipay(props) { | |||
| const { t } = useTranslation(); | |||
| const [loading, setLoading] = useState(false); | |||
| const [inputs, setInputs] = useState({ | |||
| AlipayAppID: '', | |||
| AlipayPrivateKey: '', | |||
| AlipayPublicKey: '', | |||
| AlipayNotifyURL: '', | |||
| AlipayMinTopUp: 1, | |||
| AlipayUnitPrice: 7.0, | |||
| }); | |||
| const [originInputs, setOriginInputs] = useState({}); | |||
| const formApiRef = useRef(null); | |||
| useEffect(() => { | |||
| if (props.options && formApiRef.current) { | |||
| const currentInputs = { | |||
| AlipayAppID: props.options.AlipayAppID || '', | |||
| AlipayPrivateKey: props.options.AlipayPrivateKey || '', | |||
| AlipayPublicKey: props.options.AlipayPublicKey || '', | |||
| AlipayNotifyURL: props.options.AlipayNotifyURL || '', | |||
| AlipayMinTopUp: | |||
| props.options.AlipayMinTopUp !== undefined | |||
| ? parseFloat(props.options.AlipayMinTopUp) | |||
| : 1, | |||
| AlipayUnitPrice: | |||
| props.options.AlipayUnitPrice !== undefined | |||
| ? parseFloat(props.options.AlipayUnitPrice) | |||
| : 7.0, | |||
| }; | |||
| setInputs(currentInputs); | |||
| setOriginInputs({ ...currentInputs }); | |||
| formApiRef.current.setValues(currentInputs); | |||
| } | |||
| }, [props.options]); | |||
| const handleFormChange = (values) => { | |||
| setInputs(values); | |||
| }; | |||
| const submitAlipaySetting = async () => { | |||
| if (props.options.ServerAddress === '') { | |||
| showError(t('请先填写服务器地址')); | |||
| return; | |||
| } | |||
| setLoading(true); | |||
| try { | |||
| const fields = [ | |||
| 'AlipayAppID', | |||
| 'AlipayPrivateKey', | |||
| 'AlipayPublicKey', | |||
| 'AlipayNotifyURL', | |||
| ]; | |||
| const options = []; | |||
| for (const key of fields) { | |||
| const value = inputs[key]; | |||
| if (value !== undefined && value !== originInputs[key]) { | |||
| options.push({ key, value: value || '' }); | |||
| } | |||
| } | |||
| if ( | |||
| inputs.AlipayUnitPrice !== undefined && | |||
| inputs.AlipayUnitPrice !== null && | |||
| inputs.AlipayUnitPrice !== originInputs.AlipayUnitPrice | |||
| ) { | |||
| options.push({ | |||
| key: 'AlipayUnitPrice', | |||
| value: inputs.AlipayUnitPrice.toString(), | |||
| }); | |||
| } | |||
| if ( | |||
| inputs.AlipayMinTopUp !== undefined && | |||
| inputs.AlipayMinTopUp !== null && | |||
| inputs.AlipayMinTopUp !== originInputs.AlipayMinTopUp | |||
| ) { | |||
| options.push({ | |||
| key: 'AlipayMinTopUp', | |||
| value: inputs.AlipayMinTopUp.toString(), | |||
| }); | |||
| } | |||
| 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 ( | |||
| <Spin spinning={loading}> | |||
| <Form | |||
| initValues={inputs} | |||
| onValueChange={handleFormChange} | |||
| getFormApi={(api) => (formApiRef.current = api)} | |||
| > | |||
| <Form.Section text={t('支付宝设置')}> | |||
| <Text> | |||
| 支付宝当面付(扫码支付)设置,使用 RSA2 公钥模式。请前往 | |||
| <a | |||
| href='https://open.alipay.com/' | |||
| target='_blank' | |||
| rel='noreferrer' | |||
| > | |||
| 支付宝开放平台 | |||
| </a> | |||
| 获取相关参数。 | |||
| </Text> | |||
| <Banner | |||
| type='info' | |||
| description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/alipay/pay/webhook`} | |||
| /> | |||
| <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}> | |||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||
| <Form.Input | |||
| field='AlipayAppID' | |||
| label={t('应用 AppID')} | |||
| placeholder={t('支付宝应用 AppID')} | |||
| /> | |||
| </Col> | |||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||
| <Form.Input | |||
| field='AlipayNotifyURL' | |||
| label={t('回调通知 URL')} | |||
| placeholder={t('支付成功后的回调地址')} | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}> | |||
| <Col xs={24} sm={24} md={12} lg={12} xl={12}> | |||
| <Form.TextArea | |||
| field='AlipayPrivateKey' | |||
| label={t('应用私钥(RSA2)')} | |||
| placeholder={t('应用私钥,用于请求签名')} | |||
| type='password' | |||
| rows={3} | |||
| /> | |||
| </Col> | |||
| <Col xs={24} sm={24} md={12} lg={12} xl={12}> | |||
| <Form.TextArea | |||
| field='AlipayPublicKey' | |||
| label={t('支付宝公钥')} | |||
| placeholder={t('支付宝公钥,用于回调验签')} | |||
| type='password' | |||
| rows={3} | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}> | |||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||
| <Form.InputNumber | |||
| field='AlipayUnitPrice' | |||
| precision={2} | |||
| label={t('充值单价(元/配额单位)')} | |||
| placeholder={t('例如:7,就是7元/配额单位')} | |||
| /> | |||
| </Col> | |||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||
| <Form.InputNumber | |||
| field='AlipayMinTopUp' | |||
| label={t('最低充值数量')} | |||
| placeholder={t('例如:1')} | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| <Button onClick={submitAlipaySetting}>{t('更新支付宝设置')}</Button> | |||
| </Form.Section> | |||
| </Form> | |||
| </Spin> | |||
| ); | |||
| } | |||
| @@ -210,7 +210,7 @@ export default function ModelSettingsVisualEditor(props) { | |||
| if (results.includes(undefined)) return; | |||
| } else if (requestQueue.length > 1) { | |||
| if (results.includes(undefined)) { | |||
| return showError('部分保存失败,请重试'); | |||
| return showError(t('部分保存失败,请重试')); | |||
| } | |||
| } | |||
| @@ -221,11 +221,11 @@ export default function ModelSettingsVisualEditor(props) { | |||
| } | |||
| } | |||
| showSuccess('保存成功'); | |||
| showSuccess(t('保存成功')); | |||
| props.refresh(); | |||
| } catch (error) { | |||
| console.error('保存失败:', error); | |||
| showError('保存失败,请重试'); | |||
| showError(t('保存失败,请重试')); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -309,7 +309,7 @@ export default function ModelSettingsVisualEditor(props) { | |||
| const updateModel = (name, field, value) => { | |||
| if (isNaN(value)) { | |||
| showError('请输入数字'); | |||
| showError(t('请输入数字')); | |||
| return; | |||
| } | |||
| setModels((prev) => | |||
| @@ -337,7 +337,7 @@ export default function ModelSettingsVisualEditor(props) { | |||
| completionTokenPrice, | |||
| ) => { | |||
| if (!modelTokenPrice || modelTokenPrice === '0') { | |||
| showError('模型价格不能为0'); | |||
| showError(t('模型价格不能为0')); | |||
| return ''; | |||
| } | |||
| return completionTokenPrice / modelTokenPrice; | |||
| @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import { API, showError } from '../helpers'; | |||
| import i18next from 'i18next'; | |||
| import { | |||
| prepareCredentialRequestOptions, | |||
| buildAssertionResult, | |||
| @@ -94,7 +95,7 @@ export class SecureVerificationService { | |||
| */ | |||
| static async verify2FA(code) { | |||
| if (!code?.trim()) { | |||
| throw new Error('请输入验证码或备用码'); | |||
| throw new Error(i18next.t('请输入验证码或备用码')); | |||
| } | |||
| // 调用通用验证 API,验证成功后后端会设置 session | |||
| @@ -104,7 +105,7 @@ export class SecureVerificationService { | |||
| }); | |||
| if (!verifyResponse.data?.success) { | |||
| throw new Error(verifyResponse.data?.message || '验证失败'); | |||
| throw new Error(verifyResponse.data?.message || i18next.t('验证失败')); | |||
| } | |||
| // 验证成功,session 已在后端设置 | |||
| @@ -119,7 +120,7 @@ export class SecureVerificationService { | |||
| // 开始Passkey验证 | |||
| const beginResponse = await API.post('/api/user/passkey/verify/begin'); | |||
| if (!beginResponse.data?.success) { | |||
| throw new Error(beginResponse.data?.message || '开始验证失败'); | |||
| throw new Error(beginResponse.data?.message || i18next.t('开始验证失败')); | |||
| } | |||
| // 准备WebAuthn选项 | |||
| @@ -130,7 +131,7 @@ export class SecureVerificationService { | |||
| // 执行WebAuthn验证 | |||
| const credential = await navigator.credentials.get({ publicKey }); | |||
| if (!credential) { | |||
| throw new Error('Passkey 验证被取消'); | |||
| throw new Error(i18next.t('Passkey 验证被取消')); | |||
| } | |||
| // 构建验证结果 | |||
| @@ -142,7 +143,7 @@ export class SecureVerificationService { | |||
| assertionResult, | |||
| ); | |||
| if (!finishResponse.data?.success) { | |||
| throw new Error(finishResponse.data?.message || '验证失败'); | |||
| throw new Error(finishResponse.data?.message || i18next.t('验证失败')); | |||
| } | |||
| // 调用通用验证 API 设置 session(Passkey 验证已完成) | |||
| @@ -151,15 +152,15 @@ export class SecureVerificationService { | |||
| }); | |||
| if (!verifyResponse.data?.success) { | |||
| throw new Error(verifyResponse.data?.message || '验证失败'); | |||
| throw new Error(verifyResponse.data?.message || i18next.t('验证失败')); | |||
| } | |||
| // 验证成功,session 已在后端设置 | |||
| } catch (error) { | |||
| if (error.name === 'NotAllowedError') { | |||
| throw new Error('Passkey 验证被取消或超时'); | |||
| throw new Error(i18next.t('Passkey 验证被取消或超时')); | |||
| } else if (error.name === 'InvalidStateError') { | |||
| throw new Error('Passkey 验证状态无效'); | |||
| throw new Error(i18next.t('Passkey 验证状态无效')); | |||
| } else { | |||
| throw error; | |||
| } | |||
| @@ -179,7 +180,7 @@ export class SecureVerificationService { | |||
| case 'passkey': | |||
| return await this.verifyPasskey(); | |||
| default: | |||
| throw new Error(`不支持的验证方式: ${method}`); | |||
| throw new Error(i18next.t('不支持的验证方式: {{method}}', { method })); | |||
| } | |||
| } | |||
| } | |||
| @@ -225,7 +226,7 @@ export const createApiCalls = { | |||
| response = await API.delete(url, { data }); | |||
| break; | |||
| default: | |||
| throw new Error(`不支持的HTTP方法: ${method}`); | |||
| throw new Error(i18next.t('不支持的HTTP方法: {{method}}', { method })); | |||
| } | |||
| return response.data; | |||
| }, | |||