Files
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* 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.
2026-10-09 21:39:51 +08:00

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