mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
600 lines
20 KiB
Go
600 lines
20 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"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/Calcium-Ion/go-epay/epay"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/samber/lo"
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
func GetTopUpInfo(c *gin.Context) {
|
|
complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed()
|
|
|
|
// 获取支付方式
|
|
payMethods := operation_setting.PayMethods
|
|
if !complianceConfirmed {
|
|
payMethods = []map[string]string{}
|
|
}
|
|
|
|
// 如果启用了 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": "#635BFF",
|
|
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
|
|
}
|
|
payMethods = append(payMethods, stripeMethod)
|
|
}
|
|
}
|
|
|
|
// Waffo Pancake is displayed above the standard Waffo gateway.
|
|
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": "#F97316",
|
|
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 如果启用了 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": "#3B82F6",
|
|
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
|
|
}
|
|
payMethods = append(payMethods, waffoMethod)
|
|
}
|
|
}
|
|
|
|
data := gin.H{
|
|
"enable_online_topup": isEpayTopUpEnabled(),
|
|
"enable_stripe_topup": isStripeTopUpEnabled(),
|
|
"enable_creem_topup": isCreemTopUpEnabled(),
|
|
"enable_waffo_topup": enableWaffo,
|
|
"enable_waffo_pancake_topup": enableWaffoPancake,
|
|
"enable_redemption": complianceConfirmed,
|
|
"payment_compliance_confirmed": complianceConfirmed,
|
|
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
|
|
"waffo_pay_methods": func() any {
|
|
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,
|
|
"topup_link": common.TopUpLink,
|
|
}
|
|
common.ApiSuccess(c, data)
|
|
}
|
|
|
|
type EpayRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
PaymentMethod string `json:"payment_method"`
|
|
}
|
|
|
|
type AmountRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
|
|
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)
|
|
quota, err := common.WalletQuotaFromDecimalStrict(dMinTopup.Mul(dQuotaPerUnit))
|
|
if err != nil {
|
|
return common.MaxWalletQuota
|
|
}
|
|
minTopup = quota
|
|
}
|
|
return int64(minTopup)
|
|
}
|
|
|
|
func getTopUpQuota(amount int64) (int, error) {
|
|
quota := decimal.NewFromInt(amount)
|
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
|
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
|
quota = decimal.NewFromInt(quota.Div(quotaPerUnit).IntPart()).Mul(quotaPerUnit)
|
|
} else {
|
|
quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
|
}
|
|
return common.WalletQuotaFromDecimalStrict(quota)
|
|
}
|
|
|
|
func getMaxTopUpAmount() int64 {
|
|
if common.QuotaPerUnit <= 0 {
|
|
return 0
|
|
}
|
|
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
|
maxStoredAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
|
Div(quotaPerUnit).
|
|
Floor()
|
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
|
return maxStoredAmount.Add(decimal.NewFromInt(1)).
|
|
Mul(quotaPerUnit).
|
|
Ceil().
|
|
Sub(decimal.NewFromInt(1)).
|
|
IntPart()
|
|
}
|
|
return maxStoredAmount.IntPart()
|
|
}
|
|
|
|
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
|
|
value, err := common.WalletQuotaFromDecimalStrict(quota)
|
|
if err != nil {
|
|
return 0, common.NewMessage("Top-up quota exceeds the range the system can represent")
|
|
}
|
|
if value <= 0 {
|
|
return 0, common.NewMessage("Top-up quota must be greater than 0")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func validateTopUpQuota(amount int64) (int, error) {
|
|
quota, err := getTopUpQuota(amount)
|
|
if err == nil && quota > 0 {
|
|
return quota, nil
|
|
}
|
|
maxAmount := getMaxTopUpAmount()
|
|
if maxAmount > 0 && amount > maxAmount {
|
|
return 0, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": maxAmount})
|
|
}
|
|
return 0, common.NewMessage("Invalid top-up amount")
|
|
}
|
|
|
|
// paymentError answers payment endpoints in their {"message": "error",
|
|
// "data": text} shape. When err is or wraps a *common.Message, the response
|
|
// also carries message_key and message_params for the web console.
|
|
func paymentError(c *gin.Context, err error) {
|
|
body := gin.H{"message": "error", "data": err.Error()}
|
|
var message *common.Message
|
|
if errors.As(err, &message) {
|
|
maps.Copy(body, message.Fields())
|
|
}
|
|
c.JSON(http.StatusOK, body)
|
|
}
|
|
|
|
func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decimal) bool {
|
|
creditedQuota, err := validateCreditedQuota(quota)
|
|
if err == nil {
|
|
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
|
}
|
|
if err != nil {
|
|
paymentError(c, err)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func rejectInvalidTopUpQuota(c *gin.Context, userId int, amount int64) bool {
|
|
creditedQuota, err := validateTopUpQuota(amount)
|
|
if err == nil {
|
|
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
|
}
|
|
if err != nil {
|
|
paymentError(c, err)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func RequestEpay(c *gin.Context) {
|
|
var req EpayRequest
|
|
err := c.ShouldBindJSON(&req)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
if req.Amount < getMinTopup() {
|
|
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
|
|
return
|
|
}
|
|
id := c.GetInt("id")
|
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
|
return
|
|
}
|
|
|
|
group, err := model.GetUserGroup(id, true)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Failed to get the user group"))
|
|
return
|
|
}
|
|
payMoney := getPayMoney(req.Amount, group)
|
|
if payMoney < 0.01 {
|
|
paymentError(c, common.NewMessage("Payment amount is too low"))
|
|
return
|
|
}
|
|
|
|
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
|
paymentError(c, common.NewMessage("Payment method does not exist"))
|
|
return
|
|
}
|
|
|
|
callBackAddress := service.GetCallbackAddress()
|
|
returnUrl, _ := url.Parse(paymentReturnPath("/usage-logs"))
|
|
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 {
|
|
paymentError(c, common.NewMessage("Payment information has not been configured by administrator"))
|
|
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(), common.LogText("Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
|
paymentError(c, common.NewMessage("Failed to start payment"))
|
|
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,
|
|
PaymentProvider: model.PaymentProviderEpay,
|
|
CreateTime: time.Now().Unix(),
|
|
Status: common.TopUpStatusPending,
|
|
}
|
|
err = topUp.Insert()
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
|
paymentError(c, common.NewMessage("Failed to create order"))
|
|
return
|
|
}
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up order created 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(), common.LogText("Epay webhook rejected 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(), common.LogText("Epay webhook failed to parse POST form 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(), common.LogText("Epay webhook received request 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(), common.LogText("Epay webhook params are empty 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(), common.LogText("Epay client is not initialized path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
|
_, err := c.Writer.Write([]byte("fail"))
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response 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 {
|
|
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
|
|
}
|
|
if err != nil {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
|
} else {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
|
|
}
|
|
return
|
|
}
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook signature verified 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)))
|
|
|
|
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
|
// 进程内锁只是优化;重复/并发回调的正确性由 RechargeEpay 的
|
|
// 数据库行锁 + 事务内状态校验保证(多实例部署下同样安全)。
|
|
LockOrder(verifyInfo.ServiceTradeNo)
|
|
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
|
alreadyDone, err := model.RechargeEpay(verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP())
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, model.ErrTopUpNotFound):
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
|
case errors.Is(err, model.ErrPaymentMethodMismatch):
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
|
case errors.Is(err, model.ErrTopUpStatusInvalid):
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Epay order status invalid trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
|
default:
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay top-up processing failed trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
|
|
}
|
|
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
|
}
|
|
return
|
|
}
|
|
if alreadyDone {
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
|
} else {
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
|
}
|
|
} else {
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook ignored event 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)))
|
|
}
|
|
if _, writeErr := c.Writer.Write([]byte("success")); writeErr != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
|
}
|
|
}
|
|
|
|
func RequestAmount(c *gin.Context) {
|
|
var req AmountRequest
|
|
err := c.ShouldBindJSON(&req)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
|
|
if req.Amount < getMinTopup() {
|
|
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
|
|
return
|
|
}
|
|
id := c.GetInt("id")
|
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
|
return
|
|
}
|
|
group, err := model.GetUserGroup(id, true)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Failed to get the user group"))
|
|
return
|
|
}
|
|
payMoney := getPayMoney(req.Amount, group)
|
|
if payMoney <= 0.01 {
|
|
paymentError(c, common.NewMessage("Payment amount is too low"))
|
|
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.ApiErrorT(c, "Invalid parameters")
|
|
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)
|
|
}
|