完整实现微信支付充值和订阅购买功能,支持配置热更新、二维码扫码支付、 回调验签、订单状态轮询,以及管理后台支付网关设置。 后端: - 新增微信支付 V3 客户端(支持 Base64/文件路径两种密钥加载) - Native 下单、回调处理、订单状态查询 API - 充值订单幂等完成(事务+行锁) - 订阅购买支持微信支付 - 配置变更自动重置客户端 - 提取 createWechatNativeOrder 消除重复下单逻辑 前端: - 二维码扫码弹窗(含倒计时、自动轮询状态) - 微信支付设置页面(AppID、商户号、密钥等 12 项配置) - 充值页集成微信支付流程 依赖:go-pay/gopay v1.5.117 Co-Authored-By: Claude <noreply@anthropic.com>feat/alipay-payment
| @@ -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, | |||||
| }, | |||||
| }) | |||||
| } | |||||
| @@ -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{ | 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) | common.ApiSuccess(c, data) | ||||
| } | } | ||||
| @@ -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) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -21,6 +21,7 @@ require ( | |||||
| github.com/glebarez/sqlite v1.9.0 | github.com/glebarez/sqlite v1.9.0 | ||||
| github.com/go-audio/aiff v1.1.0 | github.com/go-audio/aiff v1.1.0 | ||||
| github.com/go-audio/wav 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-playground/validator/v10 v10.20.0 | ||||
| github.com/go-redis/redis/v8 v8.11.5 | github.com/go-redis/redis/v8 v8.11.5 | ||||
| github.com/go-webauthn/webauthn v0.14.0 | github.com/go-webauthn/webauthn v0.14.0 | ||||
| @@ -47,12 +48,12 @@ require ( | |||||
| github.com/tidwall/sjson v1.2.5 | github.com/tidwall/sjson v1.2.5 | ||||
| github.com/tiktoken-go/tokenizer v0.6.2 | github.com/tiktoken-go/tokenizer v0.6.2 | ||||
| github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c | 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/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/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 | gopkg.in/yaml.v3 v3.0.1 | ||||
| gorm.io/driver/mysql v1.4.3 | gorm.io/driver/mysql v1.4.3 | ||||
| gorm.io/driver/postgres v1.5.2 | 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/audio v1.0.0 // indirect | ||||
| github.com/go-audio/riff 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-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/locales v0.14.1 // indirect | ||||
| github.com/go-playground/universal-translator v0.18.1 // indirect | github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| github.com/go-sql-driver/mysql v1.7.0 // indirect | github.com/go-sql-driver/mysql v1.7.0 // indirect | ||||
| @@ -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-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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= | ||||
| github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= | 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.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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | 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 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= | ||||
| golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= | 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.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 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/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 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= | ||||
| golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= | 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= | ||||
| golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | ||||
| golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.8.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.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.6/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.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= | 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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= | ||||
| google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||||
| @@ -89,6 +89,18 @@ func InitOptionMap() { | |||||
| common.OptionMap["CreemProducts"] = setting.CreemProducts | common.OptionMap["CreemProducts"] = setting.CreemProducts | ||||
| common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) | common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) | ||||
| common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret | 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["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() | ||||
| common.OptionMap["Chats"] = setting.Chats2JsonString() | common.OptionMap["Chats"] = setting.Chats2JsonString() | ||||
| common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() | common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() | ||||
| @@ -159,6 +171,13 @@ func InitOptionMap() { | |||||
| loadOptionsFromDatabase() | loadOptionsFromDatabase() | ||||
| } | } | ||||
| // triggerWechatPayReset 安全触发微信支付客户端重置 | |||||
| func triggerWechatPayReset() { | |||||
| if setting.OnWechatPayConfigChanged != nil { | |||||
| setting.OnWechatPayConfigChanged() | |||||
| } | |||||
| } | |||||
| func loadOptionsFromDatabase() { | func loadOptionsFromDatabase() { | ||||
| options, _ := AllOption() | options, _ := AllOption() | ||||
| for _, option := range options { | for _, option := range options { | ||||
| @@ -358,6 +377,39 @@ func updateOptionMap(key string, value string) (err error) { | |||||
| setting.CreemTestMode = value == "true" | setting.CreemTestMode = value == "true" | ||||
| case "CreemWebhookSecret": | case "CreemWebhookSecret": | ||||
| setting.CreemWebhookSecret = value | 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": | case "TopupGroupRatio": | ||||
| err = common.UpdateTopupGroupRatioByJSONString(value) | err = common.UpdateTopupGroupRatioByJSONString(value) | ||||
| case "GitHubClientId": | case "GitHubClientId": | ||||
| @@ -267,9 +267,9 @@ func ManualCompleteTopUp(tradeNo string) error { | |||||
| } | } | ||||
| // 计算应充值额度: | // 计算应充值额度: | ||||
| // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit | |||||
| // - Stripe/微信支付订单:Money 代表经分组倍率换算后的数量,直接 * QuotaPerUnit | |||||
| // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit | // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit | ||||
| if topUp.PaymentMethod == "stripe" { | |||||
| if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "wechat_pay" { | |||||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | ||||
| quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) | quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) | ||||
| } else { | } else { | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| }) | |||||
| } | |||||
| } | |||||
| @@ -48,7 +48,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | ||||
| apiRouter.POST("/creem/webhook", controller.CreemWebhook) | apiRouter.POST("/creem/webhook", controller.CreemWebhook) | ||||
| apiRouter.POST("/wechat/pay/webhook", controller.WechatPayWebhook) | |||||
| // Universal secure verification routes | // Universal secure verification routes | ||||
| apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) | 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/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) | ||||
| selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) | selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) | ||||
| selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) | 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.POST("/aff_transfer", controller.TransferAffQuota) | ||||
| selfRoute.PUT("/setting", controller.UpdateUserSetting) | selfRoute.PUT("/setting", controller.UpdateUserSetting) | ||||
| @@ -142,6 +145,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) | subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) | ||||
| subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) | subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) | ||||
| subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) | subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) | ||||
| subscriptionRoute.POST("/wechat/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWechatPay) | |||||
| } | } | ||||
| subscriptionAdminRoute := apiRouter.Group("/subscription/admin") | subscriptionAdminRoute := apiRouter.Group("/subscription/admin") | ||||
| subscriptionAdminRoute.Use(middleware.AdminAuth()) | subscriptionAdminRoute.Use(middleware.AdminAuth()) | ||||
| @@ -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() | |||||
| @@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP | |||||
| import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway'; | import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway'; | ||||
| import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; | import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; | ||||
| import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; | import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; | ||||
| import SettingsPaymentGatewayWechat from '../../pages/Setting/Payment/SettingsPaymentGatewayWechat'; | |||||
| import { API, showError, toBoolean } from '../../helpers'; | import { API, showError, toBoolean } from '../../helpers'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| @@ -98,6 +99,8 @@ const PaymentSetting = () => { | |||||
| case 'MinTopUp': | case 'MinTopUp': | ||||
| case 'StripeUnitPrice': | case 'StripeUnitPrice': | ||||
| case 'StripeMinTopUp': | case 'StripeMinTopUp': | ||||
| case 'WechatPayUnitPrice': | |||||
| case 'WechatPayMinTopUp': | |||||
| newInputs[item.key] = parseFloat(item.value); | newInputs[item.key] = parseFloat(item.value); | ||||
| break; | break; | ||||
| default: | default: | ||||
| @@ -146,6 +149,9 @@ const PaymentSetting = () => { | |||||
| <Card style={{ marginTop: '10px' }}> | <Card style={{ marginTop: '10px' }}> | ||||
| <SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} /> | <SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} /> | ||||
| </Card> | </Card> | ||||
| <Card style={{ marginTop: '10px' }}> | |||||
| <SettingsPaymentGatewayWechat options={inputs} refresh={onRefresh} /> | |||||
| </Card> | |||||
| </Spin> | </Spin> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -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 ( | |||||
| <div style={{ textAlign: 'center', padding: '20px 0' }}> | |||||
| <Text style={{ fontSize: 18, color: '#28a745' }}> | |||||
| {t('支付成功!')} | |||||
| </Text> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| if (status === 'expired') { | |||||
| return ( | |||||
| <div style={{ textAlign: 'center', padding: '20px 0' }}> | |||||
| <Text style={{ fontSize: 16, color: '#dc3545' }}> | |||||
| {t('二维码已过期,请重新发起支付')} | |||||
| </Text> | |||||
| <div style={{ marginTop: 16 }}> | |||||
| <Button onClick={onClose}>{t('关闭')}</Button> | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <div style={{ textAlign: 'center' }}> | |||||
| <div style={{ display: 'inline-block', padding: 16, background: '#fff', borderRadius: 8 }}> | |||||
| <QRCodeSVG | |||||
| value={qrCodeUrl} | |||||
| size={256} | |||||
| level='M' | |||||
| includeMargin={false} | |||||
| /> | |||||
| </div> | |||||
| <div style={{ marginTop: 16 }}> | |||||
| <Text type="secondary"> | |||||
| {t('请使用微信扫描二维码完成支付')} | |||||
| </Text> | |||||
| </div> | |||||
| <div style={{ marginTop: 8 }}> | |||||
| <Spin size="small" /> | |||||
| <Text type="secondary" style={{ marginLeft: 8 }}> | |||||
| {t('等待支付中...')} ({formatCountdown(countdown)}) | |||||
| </Text> | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| return ( | |||||
| <Modal | |||||
| title={t('微信支付')} | |||||
| visible={visible} | |||||
| onCancel={onClose} | |||||
| footer={null} | |||||
| width={400} | |||||
| centered | |||||
| > | |||||
| {renderContent()} | |||||
| </Modal> | |||||
| ); | |||||
| } | |||||
| @@ -38,6 +38,7 @@ import InvitationCard from './InvitationCard'; | |||||
| import TransferModal from './modals/TransferModal'; | import TransferModal from './modals/TransferModal'; | ||||
| import PaymentConfirmModal from './modals/PaymentConfirmModal'; | import PaymentConfirmModal from './modals/PaymentConfirmModal'; | ||||
| import TopupHistoryModal from './modals/TopupHistoryModal'; | import TopupHistoryModal from './modals/TopupHistoryModal'; | ||||
| import WechatPayQRCodeModal from './WechatPayQRCodeModal'; | |||||
| const TopUp = () => { | const TopUp = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -87,6 +88,12 @@ const TopUp = () => { | |||||
| // 账单Modal状态 | // 账单Modal状态 | ||||
| const [openHistory, setOpenHistory] = useState(false); | 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 [subscriptionPlans, setSubscriptionPlans] = useState([]); | ||||
| const [subscriptionLoading, setSubscriptionLoading] = useState(true); | const [subscriptionLoading, setSubscriptionLoading] = useState(true); | ||||
| @@ -155,6 +162,11 @@ const TopUp = () => { | |||||
| showError(t('管理员未开启Stripe充值!')); | showError(t('管理员未开启Stripe充值!')); | ||||
| return; | return; | ||||
| } | } | ||||
| } else if (payment === 'wechat_pay') { | |||||
| if (!enableWechatTopUp) { | |||||
| showError(t('管理员未开启微信支付充值!')); | |||||
| return; | |||||
| } | |||||
| } else { | } else { | ||||
| if (!enableOnlineTopUp) { | if (!enableOnlineTopUp) { | ||||
| showError(t('管理员未开启在线充值!')); | showError(t('管理员未开启在线充值!')); | ||||
| @@ -163,6 +175,31 @@ const TopUp = () => { | |||||
| } | } | ||||
| setPayWay(payment); | 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); | setPaymentLoading(true); | ||||
| try { | try { | ||||
| if (payment === 'stripe') { | if (payment === 'stripe') { | ||||
| @@ -445,14 +482,18 @@ const TopUp = () => { | |||||
| const enableStripeTopUp = data.enable_stripe_topup || false; | const enableStripeTopUp = data.enable_stripe_topup || false; | ||||
| const enableOnlineTopUp = data.enable_online_topup || false; | const enableOnlineTopUp = data.enable_online_topup || false; | ||||
| const enableCreemTopUp = data.enable_creem_topup || false; | const enableCreemTopUp = data.enable_creem_topup || false; | ||||
| const enableWechatTopUpVal = data.enable_wechat_topup || false; | |||||
| const minTopUpValue = enableOnlineTopUp | const minTopUpValue = enableOnlineTopUp | ||||
| ? data.min_topup | ? data.min_topup | ||||
| : enableStripeTopUp | : enableStripeTopUp | ||||
| ? data.stripe_min_topup | ? data.stripe_min_topup | ||||
| : 1; | |||||
| : enableWechatTopUpVal | |||||
| ? data.wechat_pay_min_topup || 1 | |||||
| : 1; | |||||
| setEnableOnlineTopUp(enableOnlineTopUp); | setEnableOnlineTopUp(enableOnlineTopUp); | ||||
| setEnableStripeTopUp(enableStripeTopUp); | setEnableStripeTopUp(enableStripeTopUp); | ||||
| setEnableCreemTopUp(enableCreemTopUp); | setEnableCreemTopUp(enableCreemTopUp); | ||||
| setEnableWechatTopUp(enableWechatTopUpVal); | |||||
| setMinTopUp(minTopUpValue); | setMinTopUp(minTopUpValue); | ||||
| setTopUpCount(minTopUpValue); | setTopUpCount(minTopUpValue); | ||||
| @@ -703,6 +744,19 @@ const TopUp = () => { | |||||
| t={t} | t={t} | ||||
| /> | /> | ||||
| {/* 微信支付二维码弹窗 */} | |||||
| <WechatPayQRCodeModal | |||||
| visible={wechatPayVisible} | |||||
| qrCodeUrl={wechatPayQRCodeUrl} | |||||
| tradeNo={wechatPayTradeNo} | |||||
| onClose={() => setWechatPayVisible(false)} | |||||
| onSuccess={() => { | |||||
| setWechatPayVisible(false); | |||||
| showSuccess(t('充值成功!')); | |||||
| userDispatch({ type: 'refresh' }); | |||||
| }} | |||||
| /> | |||||
| {/* Creem 充值确认模态框 */} | {/* Creem 充值确认模态框 */} | ||||
| <Modal | <Modal | ||||
| title={t('确定要充值 $')} | title={t('确定要充值 $')} | ||||
| @@ -0,0 +1,276 @@ | |||||
| 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 SettingsPaymentGatewayWechat(props) { | |||||
| const { t } = useTranslation(); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [inputs, setInputs] = useState({ | |||||
| WechatPayAppID: '', | |||||
| WechatPayMchID: '', | |||||
| WechatPayAPIv3Key: '', | |||||
| WechatPaySerialNo: '', | |||||
| WechatPayPubKeyID: '', | |||||
| WechatPayNotifyURL: '', | |||||
| WechatPayKeyPath: '', | |||||
| WechatPayPubKeyPath: '', | |||||
| WechatPayKeyB64: '', | |||||
| WechatPayPubKeyB64: '', | |||||
| WechatPayMinTopUp: 1, | |||||
| WechatPayUnitPrice: 7.0, | |||||
| }); | |||||
| const [originInputs, setOriginInputs] = useState({}); | |||||
| const formApiRef = useRef(null); | |||||
| useEffect(() => { | |||||
| 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 ( | |||||
| <Spin spinning={loading}> | |||||
| <Form | |||||
| initValues={inputs} | |||||
| onValueChange={handleFormChange} | |||||
| getFormApi={(api) => (formApiRef.current = api)} | |||||
| > | |||||
| <Form.Section text={t('微信支付设置')}> | |||||
| <Text> | |||||
| 微信支付 Native(扫码支付)V3 API 设置。请前往 | |||||
| <a | |||||
| href='https://pay.weixin.qq.com/' | |||||
| target='_blank' | |||||
| rel='noreferrer' | |||||
| > | |||||
| 微信支付商户平台 | |||||
| </a> | |||||
| 获取相关参数。 | |||||
| </Text> | |||||
| <Banner | |||||
| type='info' | |||||
| description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/wechat/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='WechatPayAppID' | |||||
| label={t('AppID')} | |||||
| placeholder={t('微信公众号/小程序 AppID')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.Input | |||||
| field='WechatPayMchID' | |||||
| label={t('商户号')} | |||||
| placeholder={t('微信支付商户号')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.Input | |||||
| field='WechatPayAPIv3Key' | |||||
| label={t('APIv3 密钥')} | |||||
| placeholder={t('APIv3 密钥')} | |||||
| type='password' | |||||
| /> | |||||
| </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.Input | |||||
| field='WechatPaySerialNo' | |||||
| label={t('商户证书序列号')} | |||||
| placeholder={t('商户 API 证书序列号')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.Input | |||||
| field='WechatPayPubKeyID' | |||||
| label={t('微信支付公钥 ID')} | |||||
| placeholder={t('微信支付平台公钥 ID')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.Input | |||||
| field='WechatPayNotifyURL' | |||||
| label={t('回调通知 URL')} | |||||
| placeholder={t('支付成功后的回调地址')} | |||||
| /> | |||||
| </Col> | |||||
| </Row> | |||||
| <Text style={{ marginTop: 16, display: 'block' }}> | |||||
| 私钥/公钥支持文件路径和 Base64 两种方式(二选一) | |||||
| </Text> | |||||
| <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='WechatPayKeyPath' | |||||
| label={t('商户私钥文件路径')} | |||||
| placeholder={t('如:/certs/apiclient_key.pem')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.Input | |||||
| field='WechatPayPubKeyPath' | |||||
| label={t('微信公钥文件路径')} | |||||
| placeholder={t('如:/certs/wechat_pub.pem')} | |||||
| /> | |||||
| </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.TextArea | |||||
| field='WechatPayKeyB64' | |||||
| label={t('商户私钥 Base64')} | |||||
| placeholder={t('私钥文件的 Base64 编码(与文件路径二选一)')} | |||||
| rows={3} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.TextArea | |||||
| field='WechatPayPubKeyB64' | |||||
| label={t('微信公钥 Base64')} | |||||
| placeholder={t('公钥文件的 Base64 编码(与文件路径二选一)')} | |||||
| 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='WechatPayUnitPrice' | |||||
| precision={2} | |||||
| label={t('充值单价(元/配额单位)')} | |||||
| placeholder={t('例如:7,就是7元/配额单位')} | |||||
| /> | |||||
| </Col> | |||||
| <Col xs={24} sm={24} md={8} lg={8} xl={8}> | |||||
| <Form.InputNumber | |||||
| field='WechatPayMinTopUp' | |||||
| label={t('最低充值数量')} | |||||
| placeholder={t('例如:1')} | |||||
| /> | |||||
| </Col> | |||||
| </Row> | |||||
| <Button onClick={submitWechatSetting}>{t('更新微信支付设置')}</Button> | |||||
| </Form.Section> | |||||
| </Form> | |||||
| </Spin> | |||||
| ); | |||||
| } | |||||