| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- package controller
- import (
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "sync"
- "time"
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/service"
- "github.com/QuantumNous/new-api/setting"
- "github.com/QuantumNous/new-api/setting/operation_setting"
- "github.com/QuantumNous/new-api/setting/system_setting"
- "github.com/Calcium-Ion/go-epay/epay"
- "github.com/gin-gonic/gin"
- "github.com/samber/lo"
- "github.com/shopspring/decimal"
- )
- func GetTopUpInfo(c *gin.Context) {
- // 获取支付方式
- payMethods := operation_setting.PayMethods
- // 如果启用了 Stripe 支付,添加到支付方法列表
- if isStripeTopUpEnabled() {
- // 检查是否已经包含 Stripe
- hasStripe := false
- for _, method := range payMethods {
- if method["type"] == "stripe" {
- hasStripe = true
- break
- }
- }
- if !hasStripe {
- stripeMethod := map[string]string{
- "name": "Stripe",
- "type": "stripe",
- "color": "rgba(var(--semi-purple-5), 1)",
- "min_topup": strconv.Itoa(setting.StripeMinTopUp),
- }
- payMethods = append(payMethods, stripeMethod)
- }
- }
- // 如果启用了 Waffo 支付,添加到支付方法列表
- enableWaffo := isWaffoTopUpEnabled()
- if enableWaffo {
- hasWaffo := false
- for _, method := range payMethods {
- if method["type"] == model.PaymentMethodWaffo {
- hasWaffo = true
- break
- }
- }
- if !hasWaffo {
- waffoMethod := map[string]string{
- "name": "Waffo (Global Payment)",
- "type": model.PaymentMethodWaffo,
- "color": "rgba(var(--semi-blue-5), 1)",
- "min_topup": strconv.Itoa(setting.WaffoMinTopUp),
- }
- payMethods = append(payMethods, waffoMethod)
- }
- }
- enableWaffoPancake := isWaffoPancakeTopUpEnabled()
- if enableWaffoPancake {
- hasWaffoPancake := false
- for _, method := range payMethods {
- if method["type"] == model.PaymentMethodWaffoPancake {
- hasWaffoPancake = true
- break
- }
- }
- if !hasWaffoPancake {
- payMethods = append(payMethods, map[string]string{
- "name": "Waffo Pancake",
- "type": model.PaymentMethodWaffoPancake,
- "color": "rgba(var(--semi-orange-5), 1)",
- "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
- })
- }
- }
- data := gin.H{
- "enable_online_topup": isEpayTopUpEnabled(),
- "enable_stripe_topup": isStripeTopUpEnabled(),
- "enable_creem_topup": isCreemTopUpEnabled(),
- "enable_waffo_topup": enableWaffo,
- "enable_waffo_pancake_topup": enableWaffoPancake,
- "waffo_pay_methods": func() interface{} {
- if enableWaffo {
- return setting.GetWaffoPayMethods()
- }
- return nil
- }(),
- "creem_products": setting.CreemProducts,
- "pay_methods": payMethods,
- "min_topup": operation_setting.MinTopUp,
- "stripe_min_topup": setting.StripeMinTopUp,
- "waffo_min_topup": setting.WaffoMinTopUp,
- "waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp,
- "amount_options": operation_setting.GetPaymentSetting().AmountOptions,
- "discount": operation_setting.GetPaymentSetting().AmountDiscount,
- }
- common.ApiSuccess(c, data)
- }
- type EpayRequest struct {
- Amount int64 `json:"amount"`
- PaymentMethod string `json:"payment_method"`
- }
- type AmountRequest struct {
- Amount int64 `json:"amount"`
- }
- var nonEpayPaymentMethodsForCallback = []string{
- model.PaymentMethodStripe,
- model.PaymentMethodCreem,
- model.PaymentMethodWaffo,
- model.PaymentMethodWaffoPancake,
- }
- func isNonEpayPaymentMethodForEpayCallback(paymentMethod string) bool {
- return lo.Contains(nonEpayPaymentMethodsForCallback, paymentMethod)
- }
- func GetEpayClient() *epay.Client {
- if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
- return nil
- }
- withUrl, err := epay.NewClient(&epay.Config{
- PartnerID: operation_setting.EpayId,
- Key: operation_setting.EpayKey,
- }, operation_setting.PayAddress)
- if err != nil {
- return nil
- }
- return withUrl
- }
- func getPayMoney(amount int64, group string) float64 {
- dAmount := decimal.NewFromInt(amount)
- // 充值金额以“展示类型”为准:
- // - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
- if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
- dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- dAmount = dAmount.Div(dQuotaPerUnit)
- }
- topupGroupRatio := common.GetTopupGroupRatio(group)
- if topupGroupRatio == 0 {
- topupGroupRatio = 1
- }
- dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
- dPrice := decimal.NewFromFloat(operation_setting.Price)
- // apply optional preset discount by the original request amount (if configured), default 1.0
- discount := 1.0
- if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
- if ds > 0 {
- discount = ds
- }
- }
- dDiscount := decimal.NewFromFloat(discount)
- payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
- return payMoney.InexactFloat64()
- }
- func getMinTopup() int64 {
- minTopup := operation_setting.MinTopUp
- if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
- dMinTopup := decimal.NewFromInt(int64(minTopup))
- dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
- }
- return int64(minTopup)
- }
- func RequestEpay(c *gin.Context) {
- var req EpayRequest
- err := c.ShouldBindJSON(&req)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
- return
- }
- if req.Amount < getMinTopup() {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
- return
- }
- id := c.GetInt("id")
- group, err := model.GetUserGroup(id, true)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
- return
- }
- payMoney := getPayMoney(req.Amount, group)
- if payMoney < 0.01 {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
- return
- }
- if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
- return
- }
- callBackAddress := service.GetCallbackAddress()
- returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
- notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
- tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
- tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
- client := GetEpayClient()
- if client == nil {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
- return
- }
- uri, params, err := client.Purchase(&epay.PurchaseArgs{
- Type: req.PaymentMethod,
- ServiceTradeNo: tradeNo,
- Name: fmt.Sprintf("TUC%d", req.Amount),
- Money: strconv.FormatFloat(payMoney, 'f', 2, 64),
- Device: epay.PC,
- NotifyUrl: notifyUrl,
- ReturnUrl: returnUrl,
- })
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
- return
- }
- amount := req.Amount
- if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
- dAmount := decimal.NewFromInt(int64(amount))
- dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- amount = dAmount.Div(dQuotaPerUnit).IntPart()
- }
- topUp := &model.TopUp{
- UserId: id,
- Amount: amount,
- Money: payMoney,
- TradeNo: tradeNo,
- PaymentMethod: req.PaymentMethod,
- CreateTime: time.Now().Unix(),
- Status: common.TopUpStatusPending,
- }
- err = topUp.Insert()
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
- return
- }
- logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
- c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
- }
- // tradeNo lock
- var orderLocks sync.Map
- var createLock sync.Mutex
- // refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除
- type refCountedMutex struct {
- mu sync.Mutex
- refCount int
- }
- // LockOrder 尝试对给定订单号加锁
- func LockOrder(tradeNo string) {
- createLock.Lock()
- var rcm *refCountedMutex
- if v, ok := orderLocks.Load(tradeNo); ok {
- rcm = v.(*refCountedMutex)
- } else {
- rcm = &refCountedMutex{}
- orderLocks.Store(tradeNo, rcm)
- }
- rcm.refCount++
- createLock.Unlock()
- rcm.mu.Lock()
- }
- // UnlockOrder 释放给定订单号的锁
- func UnlockOrder(tradeNo string) {
- v, ok := orderLocks.Load(tradeNo)
- if !ok {
- return
- }
- rcm := v.(*refCountedMutex)
- rcm.mu.Unlock()
- createLock.Lock()
- rcm.refCount--
- if rcm.refCount == 0 {
- orderLocks.Delete(tradeNo)
- }
- createLock.Unlock()
- }
- func EpayNotify(c *gin.Context) {
- if !isEpayWebhookEnabled() {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
- _, _ = c.Writer.Write([]byte("fail"))
- return
- }
- var params map[string]string
- if c.Request.Method == "POST" {
- // POST 请求:从 POST body 解析参数
- if err := c.Request.ParseForm(); err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
- _, _ = c.Writer.Write([]byte("fail"))
- return
- }
- params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
- r[t] = c.Request.PostForm.Get(t)
- return r
- }, map[string]string{})
- } else {
- // GET 请求:从 URL Query 解析参数
- params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
- r[t] = c.Request.URL.Query().Get(t)
- return r
- }, map[string]string{})
- }
- logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
- if len(params) == 0 {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
- _, _ = c.Writer.Write([]byte("fail"))
- return
- }
- client := GetEpayClient()
- if client == nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
- _, err := c.Writer.Write([]byte("fail"))
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
- }
- return
- }
- verifyInfo, err := client.Verify(params)
- if err == nil && verifyInfo.VerifyStatus {
- logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
- _, err := c.Writer.Write([]byte("success"))
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
- }
- } else {
- _, err := c.Writer.Write([]byte("fail"))
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
- }
- if err != nil {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
- } else {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
- }
- return
- }
- if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
- LockOrder(verifyInfo.ServiceTradeNo)
- defer UnlockOrder(verifyInfo.ServiceTradeNo)
- topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
- if topUp == nil {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
- return
- }
- if isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
- return
- }
- if topUp.PaymentMethod != verifyInfo.Type {
- logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
- return
- }
- if topUp.Status == common.TopUpStatusPending {
- topUp.Status = common.TopUpStatusSuccess
- err := topUp.Update()
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
- return
- }
- //user, _ := model.GetUserById(topUp.UserId, false)
- //user.Quota += topUp.Amount * 500000
- dAmount := decimal.NewFromInt(int64(topUp.Amount))
- dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
- err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
- return
- }
- logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
- model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
- }
- } else {
- logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
- }
- }
- func RequestAmount(c *gin.Context) {
- var req AmountRequest
- err := c.ShouldBindJSON(&req)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
- return
- }
- if req.Amount < getMinTopup() {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
- return
- }
- id := c.GetInt("id")
- group, err := model.GetUserGroup(id, true)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
- return
- }
- payMoney := getPayMoney(req.Amount, group)
- if payMoney <= 0.01 {
- c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
- }
- func GetUserTopUps(c *gin.Context) {
- userId := c.GetInt("id")
- pageInfo := common.GetPageQuery(c)
- keyword := c.Query("keyword")
- var (
- topups []*model.TopUp
- total int64
- err error
- )
- if keyword != "" {
- topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo)
- } else {
- topups, total, err = model.GetUserTopUps(userId, pageInfo)
- }
- if err != nil {
- common.ApiError(c, err)
- return
- }
- pageInfo.SetTotal(int(total))
- pageInfo.SetItems(topups)
- common.ApiSuccess(c, pageInfo)
- }
- // GetAllTopUps 管理员获取全平台充值记录
- func GetAllTopUps(c *gin.Context) {
- pageInfo := common.GetPageQuery(c)
- keyword := c.Query("keyword")
- var (
- topups []*model.TopUp
- total int64
- err error
- )
- if keyword != "" {
- topups, total, err = model.SearchAllTopUps(keyword, pageInfo)
- } else {
- topups, total, err = model.GetAllTopUps(pageInfo)
- }
- if err != nil {
- common.ApiError(c, err)
- return
- }
- pageInfo.SetTotal(int(total))
- pageInfo.SetItems(topups)
- common.ApiSuccess(c, pageInfo)
- }
- type AdminCompleteTopupRequest struct {
- TradeNo string `json:"trade_no"`
- }
- // AdminCompleteTopUp 管理员补单接口
- func AdminCompleteTopUp(c *gin.Context) {
- var req AdminCompleteTopupRequest
- if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
- common.ApiErrorMsg(c, "参数错误")
- return
- }
- // 订单级互斥,防止并发补单
- LockOrder(req.TradeNo)
- defer UnlockOrder(req.TradeNo)
- if err := model.ManualCompleteTopUp(req.TradeNo, c.ClientIP()); err != nil {
- common.ApiError(c, err)
- return
- }
- common.ApiSuccess(c, nil)
- }
|