Browse Source

feat(payment): 集成微信支付 Native(扫码支付)V3 API

完整实现微信支付充值和订阅购买功能,支持配置热更新、二维码扫码支付、
回调验签、订单状态轮询,以及管理后台支付网关设置。

后端:
- 新增微信支付 V3 客户端(支持 Base64/文件路径两种密钥加载)
- Native 下单、回调处理、订单状态查询 API
- 充值订单幂等完成(事务+行锁)
- 订阅购买支持微信支付
- 配置变更自动重置客户端
- 提取 createWechatNativeOrder 消除重复下单逻辑

前端:
- 二维码扫码弹窗(含倒计时、自动轮询状态)
- 微信支付设置页面(AppID、商户号、密钥等 12 项配置)
- 充值页集成微信支付流程

依赖:go-pay/gopay v1.5.117

Co-Authored-By: Claude <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 month ago
parent
commit
00e266cd77
16 changed files with 1821 additions and 29 deletions
  1. +109
    -0
      controller/subscription_payment_wechat.go
  2. +31
    -9
      controller/topup.go
  3. +367
    -0
      controller/topup_wechat.go
  4. +374
    -0
      controller/topup_wechat_test.go
  5. +11
    -4
      go.mod
  6. +26
    -12
      go.sum
  7. +52
    -0
      model/option.go
  8. +2
    -2
      model/topup.go
  9. +80
    -0
      model/topup_wechat.go
  10. +263
    -0
      model/topup_wechat_test.go
  11. +5
    -1
      router/api-router.go
  12. +28
    -0
      setting/payment_wechat.go
  13. +6
    -0
      web/src/components/settings/PaymentSetting.jsx
  14. +136
    -0
      web/src/components/topup/WechatPayQRCodeModal.jsx
  15. +55
    -1
      web/src/components/topup/index.jsx
  16. +276
    -0
      web/src/pages/Setting/Payment/SettingsPaymentGatewayWechat.jsx

+ 109
- 0
controller/subscription_payment_wechat.go View File

@@ -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,
},
})
}

+ 31
- 9
controller/topup.go View File

@@ -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)
}


+ 367
- 0
controller/topup_wechat.go View File

@@ -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)
}

+ 374
- 0
controller/topup_wechat_test.go View File

@@ -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)
}

+ 11
- 4
go.mod View File

@@ -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


+ 26
- 12
go.sum View File

@@ -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=


+ 52
- 0
model/option.go View File

@@ -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":


+ 2
- 2
model/topup.go View File

@@ -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 {


+ 80
- 0
model/topup_wechat.go View File

@@ -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
}

+ 263
- 0
model/topup_wechat_test.go View File

@@ -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)
})
}
}

+ 5
- 1
router/api-router.go View File

@@ -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())


+ 28
- 0
setting/payment_wechat.go View File

@@ -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()

+ 6
- 0
web/src/components/settings/PaymentSetting.jsx View File

@@ -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>
</>
);


+ 136
- 0
web/src/components/topup/WechatPayQRCodeModal.jsx View File

@@ -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>
);
}

+ 55
- 1
web/src/components/topup/index.jsx View File

@@ -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('确定要充值 $')}


+ 276
- 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayWechat.jsx View File

@@ -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>
);
}

Loading…
Cancel
Save