完整实现微信支付充值和订阅购买功能,支持配置热更新、二维码扫码支付、 回调验签、订单状态轮询,以及管理后台支付网关设置。 后端: - 新增微信支付 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{ | |||
| "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) | |||
| } | |||
| @@ -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/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 | |||
| @@ -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= | |||
| @@ -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": | |||
| @@ -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 { | |||
| @@ -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("/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()) | |||
| @@ -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 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 = () => { | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPaymentGatewayWechat options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| </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 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} | |||
| /> | |||
| {/* 微信支付二维码弹窗 */} | |||
| <WechatPayQRCodeModal | |||
| visible={wechatPayVisible} | |||
| qrCodeUrl={wechatPayQRCodeUrl} | |||
| tradeNo={wechatPayTradeNo} | |||
| onClose={() => setWechatPayVisible(false)} | |||
| onSuccess={() => { | |||
| setWechatPayVisible(false); | |||
| showSuccess(t('充值成功!')); | |||
| userDispatch({ type: 'refresh' }); | |||
| }} | |||
| /> | |||
| {/* Creem 充值确认模态框 */} | |||
| <Modal | |||
| 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> | |||
| ); | |||
| } | |||