diff --git a/controller/subscription_payment_wechat.go b/controller/subscription_payment_wechat.go new file mode 100644 index 0000000..9a5e374 --- /dev/null +++ b/controller/subscription_payment_wechat.go @@ -0,0 +1,109 @@ +package controller + +import ( + "fmt" + "log" + "math" + "net/http" + "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/thanhpk/randstr" +) + +// SubscriptionWechatPayRequest 微信支付订阅请求参数 +type SubscriptionWechatPayRequest struct { + PlanId int `json:"plan_id"` +} + +// SubscriptionRequestWechatPay 微信支付订阅购买 +func SubscriptionRequestWechatPay(c *gin.Context) { + var req SubscriptionWechatPayRequest + 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 !setting.IsWechatPayConfigured() { + common.ApiErrorMsg(c, "微信支付未配置") + return + } + + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + if user == nil { + common.ApiErrorMsg(c, "用户不存在") + return + } + + 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 + } + } + + client, err := getWechatPayClient() + if err != nil { + log.Println("获取微信支付客户端失败:", err) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "微信支付配置错误"}) + return + } + + reference := fmt.Sprintf("wx-sub-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + tradeNo := "wx_sub_" + common.Sha1([]byte(reference)) + + totalFee := int(math.Round(plan.PriceAmount * 100)) // 元 -> 分 + + codeUrl, err := createWechatNativeOrder(client, plan.Title, tradeNo, totalFee) + if err != nil { + log.Println(err) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + order := &model.SubscriptionOrder{ + UserId: userId, + PlanId: plan.Id, + Money: plan.PriceAmount, + TradeNo: tradeNo, + PaymentMethod: PaymentMethodWechatPay, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := order.Insert(); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "trade_no": tradeNo, + "qr_code_url": codeUrl, + }, + }) +} diff --git a/controller/topup.go b/controller/topup.go index a810eba..30b6e07 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -48,16 +48,38 @@ func GetTopUpInfo(c *gin.Context) { } } + // 如果启用了微信支付,添加到支付方法列表 + if setting.IsWechatPayConfigured() { + hasWechat := false + for _, method := range payMethods { + if method["type"] == PaymentMethodWechatPay { + hasWechat = true + break + } + } + if !hasWechat { + wechatMethod := map[string]string{ + "name": "WeChat Pay", + "type": PaymentMethodWechatPay, + "color": "rgba(var(--semi-green-5), 1)", + "min_topup": strconv.Itoa(setting.WechatPayMinTopUp), + } + payMethods = append(payMethods, wechatMethod) + } + } + 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 != "[]", - "creem_products": setting.CreemProducts, - "pay_methods": payMethods, - "min_topup": operation_setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, - "amount_options": operation_setting.GetPaymentSetting().AmountOptions, - "discount": operation_setting.GetPaymentSetting().AmountDiscount, + "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_wechat_topup": setting.IsWechatPayConfigured(), + "creem_products": setting.CreemProducts, + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "wechat_pay_min_topup": setting.WechatPayMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, } common.ApiSuccess(c, data) } diff --git a/controller/topup_wechat.go b/controller/topup_wechat.go new file mode 100644 index 0000000..3ba2683 --- /dev/null +++ b/controller/topup_wechat.go @@ -0,0 +1,367 @@ +package controller + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log" + "math" + "net/http" + "os" + "strconv" + "sync" + "time" + + "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" + "github.com/go-pay/gopay/wechat/v3" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodWechatPay = "wechat_pay" +) + +var wechatPayClientMu sync.Mutex +var wechatPayClient *wechat.ClientV3 + +// ResetWechatPayClient 重置微信支付客户端(配置变更时调用) +func ResetWechatPayClient() { + wechatPayClientMu.Lock() + wechatPayClient = nil + wechatPayClientMu.Unlock() +} + +func init() { + setting.OnWechatPayConfigChanged = ResetWechatPayClient +} + +// getWechatPayClient 获取或创建微信支付 V3 客户端 +func getWechatPayClient() (*wechat.ClientV3, error) { + wechatPayClientMu.Lock() + defer wechatPayClientMu.Unlock() + + if wechatPayClient != nil { + return wechatPayClient, nil + } + + if !setting.IsWechatPayConfigured() { + return nil, fmt.Errorf("微信支付未配置") + } + + var privateKey string + if setting.WechatPayKeyB64 != "" { + keyBytes, err := base64.StdEncoding.DecodeString(setting.WechatPayKeyB64) + if err != nil { + return nil, fmt.Errorf("解析商户私钥 Base64 失败: %w", err) + } + privateKey = string(keyBytes) + } else { + keyBytes, err := os.ReadFile(setting.WechatPayKeyPath) + if err != nil { + return nil, fmt.Errorf("读取商户私钥文件失败: %w", err) + } + privateKey = string(keyBytes) + } + + client, err := wechat.NewClientV3(setting.WechatPayMchID, setting.WechatPaySerialNo, setting.WechatPayAPIv3Key, privateKey) + if err != nil { + return nil, fmt.Errorf("创建微信支付客户端失败: %w", err) + } + + // 设置微信支付公钥(用于回调验签) + if setting.WechatPayPubKeyB64 != "" { + pubKeyBytes, err := base64.StdEncoding.DecodeString(setting.WechatPayPubKeyB64) + if err != nil { + return nil, fmt.Errorf("解析微信公钥 Base64 失败: %w", err) + } + if err := client.AutoVerifySignByPublicKey(pubKeyBytes, setting.WechatPayPubKeyID); err != nil { + return nil, fmt.Errorf("开启公钥验签失败: %w", err) + } + } else { + pubKeyBytes, err := os.ReadFile(setting.WechatPayPubKeyPath) + if err != nil { + return nil, fmt.Errorf("读取微信公钥文件失败: %w", err) + } + if err := client.AutoVerifySignByPublicKey(pubKeyBytes, setting.WechatPayPubKeyID); err != nil { + return nil, fmt.Errorf("开启公钥验签失败: %w", err) + } + } + + wechatPayClient = client + return wechatPayClient, nil +} + +// WechatPayRequest 微信支付请求参数 +type WechatPayRequest struct { + Amount int64 `json:"amount"` +} + +// createWechatNativeOrder 调用微信 Native 下单 API,返回二维码 URL +func createWechatNativeOrder(client *wechat.ClientV3, description, tradeNo string, totalFee int) (string, error) { + bm := make(gopay.BodyMap) + bm.Set("appid", setting.WechatPayAppID). + Set("mchid", setting.WechatPayMchID). + Set("description", description). + Set("out_trade_no", tradeNo). + Set("notify_url", setting.WechatPayNotifyURL). + SetBodyMap("amount", func(bm gopay.BodyMap) { + bm.Set("total", totalFee). + Set("currency", "CNY") + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + rx, err := client.V3TransactionNative(ctx, bm) + if err != nil { + return "", fmt.Errorf("微信统一下单失败: %w", err) + } + + if rx.Code != wechat.Success { + return "", fmt.Errorf("微信支付错误: %d - %s", rx.Code, rx.ErrResponse.Message) + } + + return rx.Response.CodeUrl, nil +} + +// RequestWechatPayAmount 计算微信支付应付金额 +func RequestWechatPayAmount(c *gin.Context) { + var req WechatPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + + minTopup := getWechatMinTopup() + 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 := getWechatPayMoney(float64(req.Amount), group) + 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)}) +} + +// RequestWechatPay 创建微信支付订单,返回二维码 URL +func RequestWechatPay(c *gin.Context) { + var req WechatPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + + minTopup := getWechatMinTopup() + 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.IsWechatPayConfigured() { + c.JSON(200, gin.H{"message": "error", "data": "微信支付未配置"}) + return + } + + client, err := getWechatPayClient() + 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 + + reference := fmt.Sprintf("wx-pay-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(4)) + tradeNo := "wx_" + common.Sha1([]byte(reference)) + + payMoney := getWechatPayMoney(float64(req.Amount), group) + totalFee := int(math.Round(payMoney * 100)) // 元 -> 分 + + codeUrl, err := createWechatNativeOrder(client, fmt.Sprintf("充值%d", req.Amount), tradeNo, totalFee) + 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: PaymentMethodWechatPay, + 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": codeUrl, + }, + }) +} + +// WechatPayStatus 轮询微信支付订单状态 +func WechatPayStatus(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, + }, + }) +} + +// WechatPayWebhook 处理微信支付回调通知 +func WechatPayWebhook(c *gin.Context) { + // 解析 V3 回调请求 + notifyReq, err := wechat.V3ParseNotify(c.Request) + if err != nil { + log.Printf("解析微信支付回调失败: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "解析回调失败"}) + return + } + + client, err := getWechatPayClient() + if err != nil { + log.Printf("获取微信支付客户端失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "服务错误"}) + return + } + + // 验证签名 + if err := notifyReq.VerifySignByPK(client.WxPublicKey()); err != nil { + log.Printf("微信支付回调验签失败: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "验签失败"}) + return + } + + // 解密回调数据 + result, err := notifyReq.DecryptPayCipherText(setting.WechatPayAPIv3Key) + if err != nil { + log.Printf("解密微信支付回调数据失败: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "解密失败"}) + return + } + + // 只处理支付成功 + if result.TradeState != "SUCCESS" { + log.Printf("微信支付回调非成功状态: %s, 订单号: %s", result.TradeState, result.OutTradeNo) + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) + return + } + + tradeNo := result.OutTradeNo + + // 先尝试完成订阅订单 + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + if err := model.CompleteSubscriptionOrder(tradeNo, common.GetJsonString(result)); err == nil { + log.Printf("微信支付订阅订单完成: %s", tradeNo) + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) + return + } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { + log.Printf("微信支付订阅订单处理失败: %s, err: %s", tradeNo, err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"}) + return + } + + // 处理充值订单 + if err := model.RechargeWechat(tradeNo); err != nil { + log.Printf("微信支付充值失败: %s, err: %s", tradeNo, err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"}) + return + } + + log.Printf("微信支付充值成功: %s, 金额: %d", tradeNo, result.Amount.Total) + 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 +} + +// getWechatMinTopup 获取微信支付最低充值数量 +func getWechatMinTopup() int64 { + minTopup := setting.WechatPayMinTopUp + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + minTopup = minTopup * int(common.QuotaPerUnit) + } + return int64(minTopup) +} diff --git a/controller/topup_wechat_test.go b/controller/topup_wechat_test.go new file mode 100644 index 0000000..af48e42 --- /dev/null +++ b/controller/topup_wechat_test.go @@ -0,0 +1,374 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/glebarez/sqlite" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupWechatControllerDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + + origDB := model.DB + origLogDB := model.LOG_DB + model.DB = db + model.LOG_DB = db + common.UsingSQLite = true + common.RedisEnabled = false + common.QuotaPerUnit = 500 * 1000.0 + + require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.Log{})) + + t.Cleanup(func() { + model.DB = origDB + model.LOG_DB = origLogDB + sqlDB.Close() + }) + return db +} + +func createTestUser(t *testing.T, db *gorm.DB, id int, username string) *model.User { + user := &model.User{ + Id: id, + Username: username, + Password: "hashed", + Quota: 100000, + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + AffCode: "aff_test_" + username, + } + require.NoError(t, db.Create(user).Error) + return user +} + +func newTestContextWithUser(userID int) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("id", userID) + return c, w +} + +func setupWechatTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/api/user/wechat/pay/amount", RequestWechatPayAmount) + r.POST("/api/user/wechat/pay", RequestWechatPay) + r.GET("/api/user/wechat/pay/status", WechatPayStatus) + return r +} + +// ========== RequestWechatPayAmount 测试 ========== + +func TestRequestWechatPayAmount_Success(t *testing.T) { + // 此测试验证 getWechatPayMoney 核心计算逻辑 + // RequestWechatPayAmount 依赖 GetUserGroup 等完整数据库,在集成测试中覆盖 + origUnitPrice := setting.WechatPayUnitPrice + setting.WechatPayUnitPrice = 7.0 + t.Cleanup(func() { + setting.WechatPayUnitPrice = origUnitPrice + }) + + // 测试不同金额的计算 + result := getWechatPayMoney(10.0, "default") + assert.InDelta(t, 70.0, result, 0.01) + + result = getWechatPayMoney(1.0, "default") + assert.InDelta(t, 7.0, result, 0.01) + + result = getWechatPayMoney(100.0, "default") + assert.InDelta(t, 700.0, result, 0.01) +} + +func TestRequestWechatPayAmount_BelowMinTopup(t *testing.T) { + setupWechatControllerDB(t) + + origMinTopUp := setting.WechatPayMinTopUp + setting.WechatPayMinTopUp = 5 + t.Cleanup(func() { + setting.WechatPayMinTopUp = origMinTopUp + }) + + body, _ := json.Marshal(WechatPayRequest{Amount: 3}) + req := httptest.NewRequest(http.MethodPost, "/api/user/wechat/pay/amount", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 400) + + RequestWechatPayAmount(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) +} + +func TestRequestWechatPayAmount_InvalidBody(t *testing.T) { + setupWechatControllerDB(t) + + req := httptest.NewRequest(http.MethodPost, "/api/user/wechat/pay/amount", bytes.NewReader([]byte("invalid"))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 400) + + RequestWechatPayAmount(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Equal(t, "参数错误", resp["data"]) +} + +// ========== RequestWechatPay 测试 ========== + +func TestRequestWechatPay_NotConfigured(t *testing.T) { + setupWechatControllerDB(t) + + // 确保微信支付未配置 + origAppID := setting.WechatPayAppID + setting.WechatPayAppID = "" + t.Cleanup(func() { + setting.WechatPayAppID = origAppID + }) + + body, _ := json.Marshal(WechatPayRequest{Amount: 10}) + req := httptest.NewRequest(http.MethodPost, "/api/user/wechat/pay", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 400) + + RequestWechatPay(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Equal(t, "微信支付未配置", resp["data"]) +} + +func TestRequestWechatPay_ExceedsMaxAmount(t *testing.T) { + setupWechatControllerDB(t) + + body, _ := json.Marshal(WechatPayRequest{Amount: 20000}) + req := httptest.NewRequest(http.MethodPost, "/api/user/wechat/pay", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 400) + + RequestWechatPay(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Contains(t, resp["data"], "不能大于 10000") +} + +func TestRequestWechatPay_BelowMinAmount(t *testing.T) { + setupWechatControllerDB(t) + + origMinTopUp := setting.WechatPayMinTopUp + setting.WechatPayMinTopUp = 5 + t.Cleanup(func() { + setting.WechatPayMinTopUp = origMinTopUp + }) + + body, _ := json.Marshal(WechatPayRequest{Amount: 3}) + req := httptest.NewRequest(http.MethodPost, "/api/user/wechat/pay", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 400) + + RequestWechatPay(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) +} + +// ========== WechatPayStatus 测试 ========== + +func TestWechatPayStatus_Success(t *testing.T) { + db := setupWechatControllerDB(t) + user := createTestUser(t, db, 401, "wx_status_test") + + // 创建订单 + topUp := &model.TopUp{ + UserId: user.Id, + Amount: 10, + Money: 70.0, + TradeNo: "wx_status_trade_001", + PaymentMethod: PaymentMethodWechatPay, + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusSuccess, + } + require.NoError(t, db.Create(topUp).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/user/wechat/pay/status?trade_no=wx_status_trade_001", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", user.Id) + + WechatPayStatus(c) + + assert.Equal(t, 200, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "success", resp["message"]) + + data := resp["data"].(map[string]interface{}) + assert.Equal(t, common.TopUpStatusSuccess, data["status"]) +} + +func TestWechatPayStatus_OrderNotFound(t *testing.T) { + setupWechatControllerDB(t) + + req := httptest.NewRequest(http.MethodGet, "/api/user/wechat/pay/status?trade_no=nonexistent", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 401) + + WechatPayStatus(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Equal(t, "订单不存在", resp["data"]) +} + +func TestWechatPayStatus_WrongUser(t *testing.T) { + db := setupWechatControllerDB(t) + createTestUser(t, db, 402, "wx_status_owner") + // 用户 403 尝试查询 402 的订单 + createTestUser(t, db, 403, "wx_status_other") + + topUp := &model.TopUp{ + UserId: 402, + Amount: 10, + Money: 70.0, + TradeNo: "wx_status_trade_wrong_user", + PaymentMethod: PaymentMethodWechatPay, + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/user/wechat/pay/status?trade_no=wx_status_trade_wrong_user", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 403) // 不同用户 + + WechatPayStatus(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Equal(t, "订单不存在", resp["data"]) // 不泄露订单存在信息 +} + +func TestWechatPayStatus_MissingTradeNo(t *testing.T) { + setupWechatControllerDB(t) + + req := httptest.NewRequest(http.MethodGet, "/api/user/wechat/pay/status", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("id", 401) + + WechatPayStatus(c) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["message"]) + assert.Equal(t, "参数错误", resp["data"]) +} + +// ========== getWechatPayMoney 测试 ========== + +func TestGetWechatPayMoney(t *testing.T) { + origUnitPrice := setting.WechatPayUnitPrice + setting.WechatPayUnitPrice = 7.0 + t.Cleanup(func() { + setting.WechatPayUnitPrice = origUnitPrice + }) + + tests := []struct { + name string + amount float64 + group string + expected float64 + }{ + {"标准计算", 10, "default", 70.0}, + {"最小金额", 1, "default", 7.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getWechatPayMoney(tt.amount, tt.group) + assert.InDelta(t, tt.expected, result, 0.01) + }) + } +} + +// ========== getWechatMinTopup 测试 ========== + +func TestGetWechatMinTopup(t *testing.T) { + origMinTopUp := setting.WechatPayMinTopUp + setting.WechatPayMinTopUp = 1 + t.Cleanup(func() { + setting.WechatPayMinTopUp = origMinTopUp + }) + + result := getWechatMinTopup() + assert.Equal(t, int64(1), result) +} + +// ========== ResetWechatPayClient 测试 ========== + +func TestResetWechatPayClient(t *testing.T) { + // 确保重置后客户端为 nil + wechatPayClient = nil + ResetWechatPayClient() + assert.Nil(t, wechatPayClient) +} + +func TestResetWechatPayClient_CallbackRegistered(t *testing.T) { + // 验证 init() 注册了回调 + assert.NotNil(t, setting.OnWechatPayConfigChanged) +} + +// ========== PaymentMethodWechatPay 常量测试 ========== + +func TestPaymentMethodWechatPay(t *testing.T) { + assert.Equal(t, "wechat_pay", PaymentMethodWechatPay) +} diff --git a/go.mod b/go.mod index cad3108..66dc76e 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/glebarez/sqlite v1.9.0 github.com/go-audio/aiff v1.1.0 github.com/go-audio/wav v1.1.0 + github.com/go-pay/gopay v1.5.117 github.com/go-playground/validator/v10 v10.20.0 github.com/go-redis/redis/v8 v8.11.5 github.com/go-webauthn/webauthn v0.14.0 @@ -47,12 +48,12 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.6.2 github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.48.0 golang.org/x/image v0.23.0 - golang.org/x/net v0.47.0 + golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.38.0 - golang.org/x/text v0.32.0 + golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 @@ -82,6 +83,12 @@ require ( github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-pay/crypto v0.0.1 // indirect + github.com/go-pay/errgroup v0.0.3 // indirect + github.com/go-pay/smap v0.0.2 // indirect + github.com/go-pay/util v0.0.4 // indirect + github.com/go-pay/xlog v0.0.3 // indirect + github.com/go-pay/xtime v0.0.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect diff --git a/go.sum b/go.sum index e7c4de1..71091f9 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,20 @@ github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-pay/crypto v0.0.1 h1:B6InT8CLfSLc6nGRVx9VMJRBBazFMjr293+jl0lLXUY= +github.com/go-pay/crypto v0.0.1/go.mod h1:41oEIvHMKbNcYlWUlRWtsnC6+ASgh7u29z0gJXe5bes= +github.com/go-pay/errgroup v0.0.3 h1:DB4s8e8oWYDyETKQ1y1riMJ7y29zE1uIsMCSjEOFSbU= +github.com/go-pay/errgroup v0.0.3/go.mod h1:0+4b8mvFMS71MIzsaC+gVvB4x37I93lRb2dqrwuU8x8= +github.com/go-pay/gopay v1.5.117 h1:9GNGk0JM+S2BjJkI+k1hJJ0YOg24GwKUQaNTGDET47M= +github.com/go-pay/gopay v1.5.117/go.mod h1:2R6Ggc9FqYnbC37yoOJZH4eZMFpykPDVOobnZNP+R2A= +github.com/go-pay/smap v0.0.2 h1:kKflYor5T5FgZltPFBMTFfjJvqYMHr5VnIFSEyhVTcA= +github.com/go-pay/smap v0.0.2/go.mod h1:HW9oAo0okuyDYsbpbj5fJFxnNj/BZorRGFw26SxrNWw= +github.com/go-pay/util v0.0.4 h1:TuwSU9o3Qd7m9v1PbzFuIA/8uO9FJnA6P7neG/NwPyk= +github.com/go-pay/util v0.0.4/go.mod h1:Tsdhs8Ib9J9b4+NKNO1PHh5hWHhlg98PthsX0ckq6PM= +github.com/go-pay/xlog v0.0.3 h1:avyMhCL/JgBHreoGx/am/kHxfs1udDOAeVqbmzP/Yes= +github.com/go-pay/xlog v0.0.3/go.mod h1:mH47xbobrdsSHWsmFtSF5agWbMHFP+tK0ZbVCk5OAEw= +github.com/go-pay/xtime v0.0.2 h1:7YR4/iuELsEHpJ6LUO0SVK80hQxDO9MLCfuVYIiTCRM= +github.com/go-pay/xtime v0.0.2/go.mod h1:W1yRbJaSt4CSBcdAtLBQ8xajiN/Pl5hquGczUcUE9xE= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -319,18 +333,18 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -344,18 +358,18 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/model/option.go b/model/option.go index 697e77d..5af2dbb 100644 --- a/model/option.go +++ b/model/option.go @@ -89,6 +89,18 @@ func InitOptionMap() { common.OptionMap["CreemProducts"] = setting.CreemProducts common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret + common.OptionMap["WechatPayAppID"] = setting.WechatPayAppID + common.OptionMap["WechatPayMchID"] = setting.WechatPayMchID + common.OptionMap["WechatPayAPIv3Key"] = setting.WechatPayAPIv3Key + common.OptionMap["WechatPaySerialNo"] = setting.WechatPaySerialNo + common.OptionMap["WechatPayPubKeyID"] = setting.WechatPayPubKeyID + common.OptionMap["WechatPayNotifyURL"] = setting.WechatPayNotifyURL + common.OptionMap["WechatPayKeyPath"] = setting.WechatPayKeyPath + common.OptionMap["WechatPayPubKeyPath"] = setting.WechatPayPubKeyPath + common.OptionMap["WechatPayKeyB64"] = setting.WechatPayKeyB64 + common.OptionMap["WechatPayPubKeyB64"] = setting.WechatPayPubKeyB64 + common.OptionMap["WechatPayMinTopUp"] = strconv.Itoa(setting.WechatPayMinTopUp) + common.OptionMap["WechatPayUnitPrice"] = strconv.FormatFloat(setting.WechatPayUnitPrice, 'f', -1, 64) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -159,6 +171,13 @@ func InitOptionMap() { loadOptionsFromDatabase() } +// triggerWechatPayReset 安全触发微信支付客户端重置 +func triggerWechatPayReset() { + if setting.OnWechatPayConfigChanged != nil { + setting.OnWechatPayConfigChanged() + } +} + func loadOptionsFromDatabase() { options, _ := AllOption() for _, option := range options { @@ -358,6 +377,39 @@ func updateOptionMap(key string, value string) (err error) { setting.CreemTestMode = value == "true" case "CreemWebhookSecret": setting.CreemWebhookSecret = value + case "WechatPayAppID": + setting.WechatPayAppID = value + triggerWechatPayReset() + case "WechatPayMchID": + setting.WechatPayMchID = value + triggerWechatPayReset() + case "WechatPayAPIv3Key": + setting.WechatPayAPIv3Key = value + triggerWechatPayReset() + case "WechatPaySerialNo": + setting.WechatPaySerialNo = value + triggerWechatPayReset() + case "WechatPayPubKeyID": + setting.WechatPayPubKeyID = value + triggerWechatPayReset() + case "WechatPayNotifyURL": + setting.WechatPayNotifyURL = value + case "WechatPayKeyPath": + setting.WechatPayKeyPath = value + triggerWechatPayReset() + case "WechatPayPubKeyPath": + setting.WechatPayPubKeyPath = value + triggerWechatPayReset() + case "WechatPayKeyB64": + setting.WechatPayKeyB64 = value + triggerWechatPayReset() + case "WechatPayPubKeyB64": + setting.WechatPayPubKeyB64 = value + triggerWechatPayReset() + case "WechatPayMinTopUp": + setting.WechatPayMinTopUp, _ = strconv.Atoi(value) + case "WechatPayUnitPrice": + setting.WechatPayUnitPrice, _ = strconv.ParseFloat(value, 64) case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/model/topup.go b/model/topup.go index 655d9b7..fd2f74d 100644 --- a/model/topup.go +++ b/model/topup.go @@ -267,9 +267,9 @@ func ManualCompleteTopUp(tradeNo string) error { } // 计算应充值额度: - // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit + // - Stripe/微信支付订单:Money 代表经分组倍率换算后的数量,直接 * QuotaPerUnit // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit - if topUp.PaymentMethod == "stripe" { + if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "wechat_pay" { dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) } else { diff --git a/model/topup_wechat.go b/model/topup_wechat.go new file mode 100644 index 0000000..0b8772a --- /dev/null +++ b/model/topup_wechat.go @@ -0,0 +1,80 @@ +package model + +import ( + "errors" + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// RechargeWechat 微信支付充值完成(由回调触发) +// 与 Recharge/RechargeCreem 类似,使用事务+行锁保证幂等 +func RechargeWechat(tradeNo string) error { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + + var quotaToAdd int64 + var payMoney float64 + var userId int + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err := DB.Transaction(func(tx *gorm.DB) error { + topUp := &TopUp{} + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status == common.TopUpStatusSuccess { + // 已处理,幂等返回 + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + 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() + + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + userId = topUp.UserId + payMoney = topUp.Money + return nil + }) + + if err != nil { + return err + } + + if quotaToAdd > 0 { + RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用微信支付充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney)) + } + + return nil +} diff --git a/model/topup_wechat_test.go b/model/topup_wechat_test.go new file mode 100644 index 0000000..d68e137 --- /dev/null +++ b/model/topup_wechat_test.go @@ -0,0 +1,263 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupWechatTopUpDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + + origDB := DB + origLogDB := LOG_DB + DB = db + LOG_DB = db + common.UsingSQLite = true + common.RedisEnabled = false + common.QuotaPerUnit = 500 * 1000.0 + + require.NoError(t, db.AutoMigrate(&User{}, &TopUp{}, &Log{})) + + t.Cleanup(func() { + DB = origDB + LOG_DB = origLogDB + sqlDB.Close() + }) + return db +} + +func TestRechargeWechat_Success(t *testing.T) { + db := setupWechatTopUpDB(t) + + // 创建测试用户 + user := User{ + Id: 200, + Username: "wechat_test_user", + Password: "hashed_password", + Quota: 100000, + AffCode: "aff_wx_200", + } + require.NoError(t, db.Create(&user).Error) + + // 创建 pending 状态的充值订单 + expectedQuota := int(decimal.NewFromFloat(10.0).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()) + topUp := &TopUp{ + UserId: 200, + Amount: 10, + Money: 10.0, // 10 元 + TradeNo: "wx_test_trade_001", + PaymentMethod: "wechat_pay", + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // 执行充值 + err := RechargeWechat("wx_test_trade_001") + require.NoError(t, err) + + // 验证用户额度已增加 + var updatedUser User + require.NoError(t, db.First(&updatedUser, 200).Error) + assert.Equal(t, 100000+expectedQuota, updatedUser.Quota) + + // 验证订单状态已更新 + var updatedTopUp TopUp + require.NoError(t, db.Where("trade_no = ?", "wx_test_trade_001").First(&updatedTopUp).Error) + assert.Equal(t, common.TopUpStatusSuccess, updatedTopUp.Status) + assert.NotZero(t, updatedTopUp.CompleteTime) +} + +func TestRechargeWechat_OrderNotFound(t *testing.T) { + setupWechatTopUpDB(t) + + err := RechargeWechat("non_existent_trade_no") + assert.Error(t, err) + assert.Contains(t, err.Error(), "充值订单不存在") +} + +func TestRechargeWechat_EmptyTradeNo(t *testing.T) { + setupWechatTopUpDB(t) + + err := RechargeWechat("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "未提供支付单号") +} + +func TestRechargeWechat_AlreadyCompleted(t *testing.T) { + db := setupWechatTopUpDB(t) + + // 创建测试用户 + user := User{ + Id: 201, + Username: "wechat_test_user_2", + Password: "hashed_password", + Quota: 100000, + AffCode: "aff_wx_201", + } + require.NoError(t, db.Create(&user).Error) + + // 创建已完成的充值订单 + topUp := &TopUp{ + UserId: 201, + Amount: 10, + Money: 10.0, + TradeNo: "wx_test_trade_completed", + PaymentMethod: "wechat_pay", + CreateTime: common.GetTimestamp(), + CompleteTime: common.GetTimestamp(), + Status: common.TopUpStatusSuccess, + } + require.NoError(t, db.Create(topUp).Error) + + // 再次充值应幂等返回成功 + err := RechargeWechat("wx_test_trade_completed") + require.NoError(t, err) + + // 验证额度未变化 + var updatedUser User + require.NoError(t, db.First(&updatedUser, 201).Error) + assert.Equal(t, 100000, updatedUser.Quota) // 额度不变 +} + +func TestRechargeWechat_InvalidStatus(t *testing.T) { + db := setupWechatTopUpDB(t) + + // 创建测试用户 + user := User{ + Id: 202, + Username: "wechat_test_user_3", + Password: "hashed_password", + Quota: 100000, + AffCode: "aff_wx_202", + } + require.NoError(t, db.Create(&user).Error) + + // 创建 expired 状态的订单 + topUp := &TopUp{ + UserId: 202, + Amount: 10, + Money: 10.0, + TradeNo: "wx_test_trade_expired", + PaymentMethod: "wechat_pay", + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusExpired, + } + require.NoError(t, db.Create(topUp).Error) + + // 应返回状态错误 + err := RechargeWechat("wx_test_trade_expired") + assert.Error(t, err) + assert.Contains(t, err.Error(), "充值订单状态错误") + + // 验证额度未变化 + var updatedUser User + require.NoError(t, db.First(&updatedUser, 202).Error) + assert.Equal(t, 100000, updatedUser.Quota) +} + +func TestRechargeWechat_ConcurrentSafety(t *testing.T) { + db := setupWechatTopUpDB(t) + + // 创建测试用户 + user := User{ + Id: 203, + Username: "wechat_concurrent_user", + Password: "hashed_password", + Quota: 100000, + AffCode: "aff_wx_203", + } + require.NoError(t, db.Create(&user).Error) + + expectedQuota := int(decimal.NewFromFloat(10.0).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()) + + // 创建 pending 订单 + topUp := &TopUp{ + UserId: 203, + Amount: 10, + Money: 10.0, + TradeNo: "wx_concurrent_trade", + PaymentMethod: "wechat_pay", + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // 并发调用 RechargeWechat,只有第一个应该成功充值 + done := make(chan error, 2) + go func() { done <- RechargeWechat("wx_concurrent_trade") }() + go func() { done <- RechargeWechat("wx_concurrent_trade") }() + + err1 := <-done + err2 := <-done + + // 至少一个成功,不能两个都失败 + require.True(t, err1 == nil || err2 == nil, "至少一个并发请求应成功") + + // 验证额度只增加了一次(因为 SQLite 单连接,实际上是序列化的) + var updatedUser User + require.NoError(t, db.First(&updatedUser, 203).Error) + assert.Equal(t, 100000+expectedQuota, updatedUser.Quota, "额度应只增加一次") +} + +func TestRechargeWechat_QuotaCalculation(t *testing.T) { + db := setupWechatTopUpDB(t) + + testCases := []struct { + name string + money float64 + initialQuota int + expectedAdd int + userId int + }{ + {"整数金额", 7.0, 0, int(decimal.NewFromFloat(7.0).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()), 310}, + {"小数金额", 7.5, 0, int(decimal.NewFromFloat(7.5).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()), 311}, + {"最小金额", 0.01, 0, int(decimal.NewFromFloat(0.01).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()), 312}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + userId := tc.userId + tradeNo := "wx_quota_test_" + tc.name + + // 创建用户 + user := User{ + Id: userId, + Username: "quota_test_" + tc.name, + Password: "hashed", + Quota: tc.initialQuota, + AffCode: "aff_" + tc.name, + } + require.NoError(t, db.Create(&user).Error) + + // 创建订单 + topUp := &TopUp{ + UserId: userId, + Amount: 1, + Money: tc.money, + TradeNo: tradeNo, + PaymentMethod: "wechat_pay", + CreateTime: common.GetTimestamp(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + err := RechargeWechat(tradeNo) + require.NoError(t, err) + + var updatedUser User + require.NoError(t, db.First(&updatedUser, userId).Error) + assert.Equal(t, tc.initialQuota+tc.expectedAdd, updatedUser.Quota, tc.name) + }) + } +} diff --git a/router/api-router.go b/router/api-router.go index 9e87f8d..9fe742d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -48,7 +48,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) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -90,6 +90,9 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) + selfRoute.POST("/wechat/pay/amount", controller.RequestWechatPayAmount) + selfRoute.POST("/wechat/pay", middleware.CriticalRateLimit(), controller.RequestWechatPay) + selfRoute.GET("/wechat/pay/status", middleware.CriticalRateLimit(), controller.WechatPayStatus) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) @@ -142,6 +145,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) + subscriptionRoute.POST("/wechat/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWechatPay) } subscriptionAdminRoute := apiRouter.Group("/subscription/admin") subscriptionAdminRoute.Use(middleware.AdminAuth()) diff --git a/setting/payment_wechat.go b/setting/payment_wechat.go new file mode 100644 index 0000000..2c3bfc7 --- /dev/null +++ b/setting/payment_wechat.go @@ -0,0 +1,28 @@ +package setting + +var WechatPayAppID = "" +var WechatPayMchID = "" +var WechatPayAPIv3Key = "" +var WechatPaySerialNo = "" +var WechatPayPubKeyID = "" +var WechatPayNotifyURL = "" +var WechatPayKeyPath = "" +var WechatPayPubKeyPath = "" +var WechatPayKeyB64 = "" +var WechatPayPubKeyB64 = "" +var WechatPayMinTopUp = 1 +var WechatPayUnitPrice = 7.0 + +// IsWechatPayConfigured 检查微信支付核心配置是否完整 +func IsWechatPayConfigured() bool { + return WechatPayAppID != "" && + WechatPayMchID != "" && + WechatPayAPIv3Key != "" && + WechatPaySerialNo != "" && + WechatPayPubKeyID != "" && + (WechatPayKeyPath != "" || WechatPayKeyB64 != "") && + (WechatPayPubKeyPath != "" || WechatPayPubKeyB64 != "") +} + +// OnWechatPayConfigChanged 配置变更时调用的回调函数(由 controller 包注册) +var OnWechatPayConfigChanged func() diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 28cbf13..a375932 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway'; import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; +import SettingsPaymentGatewayWechat from '../../pages/Setting/Payment/SettingsPaymentGatewayWechat'; import { API, showError, toBoolean } from '../../helpers'; import { useTranslation } from 'react-i18next'; @@ -98,6 +99,8 @@ const PaymentSetting = () => { case 'MinTopUp': case 'StripeUnitPrice': case 'StripeMinTopUp': + case 'WechatPayUnitPrice': + case 'WechatPayMinTopUp': newInputs[item.key] = parseFloat(item.value); break; default: @@ -146,6 +149,9 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/components/topup/WechatPayQRCodeModal.jsx b/web/src/components/topup/WechatPayQRCodeModal.jsx new file mode 100644 index 0000000..200f63a --- /dev/null +++ b/web/src/components/topup/WechatPayQRCodeModal.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { Modal, Typography, Spin, Button } from '@douyinfe/semi-ui'; +import { QRCodeSVG } from 'qrcode.react'; +import { API } from '../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +const POLL_INTERVAL = 2000; + +export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onClose, onSuccess }) { + const { t } = useTranslation(); + const [status, setStatus] = useState('pending'); + const [countdown, setCountdown] = useState(300); + const timerRef = useRef(null); + const pollRef = useRef(null); + + const pollStatus = useCallback(async () => { + if (!tradeNo) return; + try { + const res = await API.get(`/api/user/wechat/pay/status?trade_no=${tradeNo}`); + const { data } = res.data; + if (data?.status === 'success') { + setStatus('success'); + clearInterval(pollRef.current); + clearInterval(timerRef.current); + if (onSuccess) { + setTimeout(onSuccess, 1000); + } + } else if (data?.status === 'expired') { + setStatus('expired'); + clearInterval(pollRef.current); + clearInterval(timerRef.current); + } + } catch (e) { + // 忽略轮询错误 + } + }, [tradeNo, onSuccess]); + + useEffect(() => { + if (visible && tradeNo && status === 'pending') { + pollRef.current = setInterval(pollStatus, POLL_INTERVAL); + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + setStatus('expired'); + clearInterval(pollRef.current); + clearInterval(timerRef.current); + return 0; + } + return prev - 1; + }); + }, 1000); + } + + return () => { + clearInterval(pollRef.current); + clearInterval(timerRef.current); + }; + }, [visible, tradeNo, status, pollStatus]); + + useEffect(() => { + if (visible) { + setStatus('pending'); + setCountdown(300); + } + }, [visible]); + + const formatCountdown = (seconds) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + const renderContent = () => { + if (status === 'success') { + return ( +
+ + {t('支付成功!')} + +
+ ); + } + + if (status === 'expired') { + return ( +
+ + {t('二维码已过期,请重新发起支付')} + +
+ +
+
+ ); + } + + return ( +
+
+ +
+
+ + {t('请使用微信扫描二维码完成支付')} + +
+
+ + + {t('等待支付中...')} ({formatCountdown(countdown)}) + +
+
+ ); + }; + + return ( + + {renderContent()} + + ); +} diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 55c7a07..949714b 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -38,6 +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'; const TopUp = () => { const { t } = useTranslation(); @@ -87,6 +88,12 @@ const TopUp = () => { // 账单Modal状态 const [openHistory, setOpenHistory] = useState(false); + // 微信支付相关状态 + const [wechatPayVisible, setWechatPayVisible] = useState(false); + const [wechatPayQRCodeUrl, setWechatPayQRCodeUrl] = useState(''); + const [wechatPayTradeNo, setWechatPayTradeNo] = useState(''); + const [enableWechatTopUp, setEnableWechatTopUp] = useState(false); + // 订阅相关 const [subscriptionPlans, setSubscriptionPlans] = useState([]); const [subscriptionLoading, setSubscriptionLoading] = useState(true); @@ -155,6 +162,11 @@ const TopUp = () => { showError(t('管理员未开启Stripe充值!')); return; } + } else if (payment === 'wechat_pay') { + if (!enableWechatTopUp) { + showError(t('管理员未开启微信支付充值!')); + return; + } } else { if (!enableOnlineTopUp) { showError(t('管理员未开启在线充值!')); @@ -163,6 +175,31 @@ const TopUp = () => { } setPayWay(payment); + + // 微信支付直接创建订单并显示二维码 + if (payment === 'wechat_pay') { + setPaymentLoading(true); + try { + const res = await API.post('/api/user/wechat/pay', { + amount: parseInt(topUpCount), + }); + const { message, data } = res.data; + if (message === 'success') { + setWechatPayQRCodeUrl(data.qr_code_url); + setWechatPayTradeNo(data.trade_no); + setWechatPayVisible(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') { @@ -445,14 +482,18 @@ const TopUp = () => { const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; const enableCreemTopUp = data.enable_creem_topup || false; + const enableWechatTopUpVal = data.enable_wechat_topup || false; const minTopUpValue = enableOnlineTopUp ? data.min_topup : enableStripeTopUp ? data.stripe_min_topup - : 1; + : enableWechatTopUpVal + ? data.wechat_pay_min_topup || 1 + : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); setEnableCreemTopUp(enableCreemTopUp); + setEnableWechatTopUp(enableWechatTopUpVal); setMinTopUp(minTopUpValue); setTopUpCount(minTopUpValue); @@ -703,6 +744,19 @@ const TopUp = () => { t={t} /> + {/* 微信支付二维码弹窗 */} + setWechatPayVisible(false)} + onSuccess={() => { + setWechatPayVisible(false); + showSuccess(t('充值成功!')); + userDispatch({ type: 'refresh' }); + }} + /> + {/* Creem 充值确认模态框 */} { + if (props.options && formApiRef.current) { + const currentInputs = { + WechatPayAppID: props.options.WechatPayAppID || '', + WechatPayMchID: props.options.WechatPayMchID || '', + WechatPayAPIv3Key: props.options.WechatPayAPIv3Key || '', + WechatPaySerialNo: props.options.WechatPaySerialNo || '', + WechatPayPubKeyID: props.options.WechatPayPubKeyID || '', + WechatPayNotifyURL: props.options.WechatPayNotifyURL || '', + WechatPayKeyPath: props.options.WechatPayKeyPath || '', + WechatPayPubKeyPath: props.options.WechatPayPubKeyPath || '', + WechatPayKeyB64: props.options.WechatPayKeyB64 || '', + WechatPayPubKeyB64: props.options.WechatPayPubKeyB64 || '', + WechatPayMinTopUp: + props.options.WechatPayMinTopUp !== undefined + ? parseFloat(props.options.WechatPayMinTopUp) + : 1, + WechatPayUnitPrice: + props.options.WechatPayUnitPrice !== undefined + ? parseFloat(props.options.WechatPayUnitPrice) + : 7.0, + }; + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitWechatSetting = async () => { + if (props.options.ServerAddress === '') { + showError(t('请先填写服务器地址')); + return; + } + + setLoading(true); + try { + const fields = [ + 'WechatPayAppID', + 'WechatPayMchID', + 'WechatPayAPIv3Key', + 'WechatPaySerialNo', + 'WechatPayPubKeyID', + 'WechatPayNotifyURL', + 'WechatPayKeyPath', + 'WechatPayPubKeyPath', + 'WechatPayKeyB64', + 'WechatPayPubKeyB64', + ]; + + const options = []; + + for (const key of fields) { + const value = inputs[key]; + if (value !== undefined && value !== originInputs[key]) { + options.push({ key, value: value || '' }); + } + } + + if ( + inputs.WechatPayUnitPrice !== undefined && + inputs.WechatPayUnitPrice !== null && + inputs.WechatPayUnitPrice !== originInputs.WechatPayUnitPrice + ) { + options.push({ + key: 'WechatPayUnitPrice', + value: inputs.WechatPayUnitPrice.toString(), + }); + } + if ( + inputs.WechatPayMinTopUp !== undefined && + inputs.WechatPayMinTopUp !== null && + inputs.WechatPayMinTopUp !== originInputs.WechatPayMinTopUp + ) { + options.push({ + key: 'WechatPayMinTopUp', + value: inputs.WechatPayMinTopUp.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 ( + +
(formApiRef.current = api)} + > + + + 微信支付 Native(扫码支付)V3 API 设置。请前往 + + 微信支付商户平台 + + 获取相关参数。 + + + + + + + + + + + + + + + + + + + + + + + + + + 私钥/公钥支持文件路径和 Base64 两种方式(二选一) + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}