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