mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +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.
716 lines
21 KiB
Go
716 lines
21 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
|
|
"github.com/shopspring/decimal"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type TopUp struct {
|
|
Id int `json:"id"`
|
|
UserId int `json:"user_id" gorm:"index"`
|
|
Amount int64 `json:"amount"`
|
|
Money float64 `json:"money"`
|
|
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
|
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
|
|
PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"`
|
|
CreateTime int64 `json:"create_time"`
|
|
CompleteTime int64 `json:"complete_time"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
const (
|
|
PaymentMethodStripe = "stripe"
|
|
PaymentMethodCreem = "creem"
|
|
PaymentMethodWaffo = "waffo"
|
|
PaymentMethodWaffoPancake = "waffo_pancake"
|
|
PaymentMethodBalance = "balance"
|
|
)
|
|
|
|
const (
|
|
PaymentProviderEpay = "epay"
|
|
PaymentProviderStripe = "stripe"
|
|
PaymentProviderCreem = "creem"
|
|
PaymentProviderWaffo = "waffo"
|
|
PaymentProviderWaffoPancake = "waffo_pancake"
|
|
PaymentProviderBalance = "balance"
|
|
)
|
|
|
|
var (
|
|
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
|
ErrTopUpNotFound = errors.New("topup not found")
|
|
ErrTopUpStatusInvalid = errors.New("topup status invalid")
|
|
// The quota errors reach the web console, so they carry message keys.
|
|
ErrInvalidTopUpQuota error = common.NewMessage("Invalid top-up quota")
|
|
ErrTopUpQuotaLimitExceeded error = common.NewMessage("Top-up quota limit exceeded")
|
|
ErrWalletQuotaLimitExceeded error = common.NewMessage("Wallet quota limit exceeded")
|
|
)
|
|
|
|
func (topUp *TopUp) Insert() error {
|
|
var err error
|
|
err = DB.Create(topUp).Error
|
|
return err
|
|
}
|
|
|
|
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
|
|
if creditedQuota <= 0 || creditedQuota > common.MaxWalletQuota {
|
|
return 0, ErrInvalidTopUpQuota
|
|
}
|
|
return common.MaxWalletQuota - creditedQuota, nil
|
|
}
|
|
|
|
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
|
|
// settlement path repeats the same invariant with an atomic conditional
|
|
// update, because the wallet balance can change after checkout creation.
|
|
func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
|
|
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var user User
|
|
if err := DB.Select("quota").Where("id = ?", userId).First(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
if user.Quota > maxCurrentQuota {
|
|
return ErrTopUpQuotaLimitExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// creditTopUpQuota atomically enforces the wallet ceiling while adding quota.
|
|
// Keeping the predicate and increment in one UPDATE prevents two
|
|
// concurrent callbacks from both passing a separate read/check.
|
|
func creditTopUpQuota(tx *gorm.DB, userId int, creditedQuota int, updates map[string]any) error {
|
|
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updateFields := make(map[string]any, len(updates)+1)
|
|
maps.Copy(updateFields, updates)
|
|
updateFields["quota"] = gorm.Expr("quota + ?", creditedQuota)
|
|
|
|
result := tx.Model(&User{}).
|
|
Where("id = ? AND quota <= ?", userId, maxCurrentQuota).
|
|
Updates(updateFields)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 1 {
|
|
return nil
|
|
}
|
|
|
|
var count int64
|
|
if err := tx.Model(&User{}).Where("id = ?", userId).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return ErrTopUpQuotaLimitExceeded
|
|
}
|
|
|
|
func (topUp *TopUp) Update() error {
|
|
var err error
|
|
err = DB.Save(topUp).Error
|
|
return err
|
|
}
|
|
|
|
func GetTopUpById(id int) *TopUp {
|
|
var topUp *TopUp
|
|
var err error
|
|
err = DB.Where("id = ?", id).First(&topUp).Error
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return topUp
|
|
}
|
|
|
|
func GetTopUpByTradeNo(tradeNo string) *TopUp {
|
|
var topUp *TopUp
|
|
var err error
|
|
err = DB.Where("trade_no = ?", tradeNo).First(&topUp).Error
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return topUp
|
|
}
|
|
|
|
func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, targetStatus string) error {
|
|
if tradeNo == "" {
|
|
return errors.New("payment order number not provided")
|
|
}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
topUp := &TopUp{}
|
|
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
|
|
return ErrTopUpNotFound
|
|
}
|
|
if expectedPaymentProvider != "" && topUp.PaymentProvider != expectedPaymentProvider {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return ErrTopUpStatusInvalid
|
|
}
|
|
|
|
topUp.Status = targetStatus
|
|
return tx.Save(topUp).Error
|
|
})
|
|
}
|
|
|
|
// RechargeEpay 原子完成易支付订单:订单行锁、状态校验、成功更新与用户额度增加
|
|
// 在同一个事务内完成,因此同一订单的并发/重复回调(包括多实例部署下)最多充值一次。
|
|
// alreadyDone=true 表示订单此前已完成,本次为幂等重复回调。
|
|
// 进程内的 LockOrder 只是优化,正确性由本函数的数据库行锁保证。
|
|
func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (alreadyDone bool, err error) {
|
|
if tradeNo == "" {
|
|
return false, errors.New("payment order number not provided")
|
|
}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
var quotaToAdd int
|
|
topUp := &TopUp{}
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
|
|
return ErrTopUpNotFound
|
|
}
|
|
if topUp.PaymentProvider != PaymentProviderEpay {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
if topUp.Status == common.TopUpStatusSuccess {
|
|
alreadyDone = true
|
|
return nil
|
|
}
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return ErrTopUpStatusInvalid
|
|
}
|
|
if actualPaymentMethod != "" && topUp.PaymentMethod != actualPaymentMethod {
|
|
topUp.PaymentMethod = actualPaymentMethod
|
|
}
|
|
var quotaErr error
|
|
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
if quotaErr != nil || quotaToAdd <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
if err := tx.Save(topUp).Error; err != nil {
|
|
return err
|
|
}
|
|
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) {
|
|
common.SysError(common.LogText("epay topup failed: %s", err.Error()))
|
|
}
|
|
return false, err
|
|
}
|
|
if alreadyDone {
|
|
return true, nil
|
|
}
|
|
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, common.LogText("epay topup"))
|
|
|
|
common.SysLog(common.LogText("Epay top-up succeeded trade_no=%s user_id=%d quota_to_add=%d money=%.2f", topUp.TradeNo, topUp.UserId, quotaToAdd, topUp.Money))
|
|
RecordTopupLog(topUp.UserId, common.NewMessage("Online top-up succeeded, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"quota": logger.FormatQuota(quotaToAdd), "amount": fmt.Sprintf("%f", topUp.Money)}), callerIp, topUp.PaymentMethod, PaymentProviderEpay)
|
|
return false, nil
|
|
}
|
|
|
|
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
|
|
if referenceId == "" {
|
|
return errors.New("payment order number not provided")
|
|
}
|
|
|
|
var quota int
|
|
topUp := &TopUp{}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
err := lockForUpdate(tx).Where(refCol+" = ?", referenceId).First(topUp).Error
|
|
if err != nil {
|
|
return errors.New("top-up order not found")
|
|
}
|
|
|
|
if topUp.PaymentProvider != PaymentProviderStripe {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return errors.New("invalid top-up order status")
|
|
}
|
|
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
err = tx.Save(topUp).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
quota, err = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
if err != nil || quota <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
return creditTopUpQuota(tx, topUp.UserId, quota, map[string]any{
|
|
"stripe_customer": customerId,
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
common.SysError(common.LogText("topup failed: %s", err.Error()))
|
|
return errors.New("top-up failed")
|
|
}
|
|
syncCreditUserQuotaCache(topUp.UserId, quota, common.LogText("stripe topup"))
|
|
|
|
RecordTopupLog(topUp.UserId, common.NewMessage("Online top-up succeeded, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"quota": logger.FormatQuota(quota), "amount": topUp.Amount}), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
|
|
|
|
return nil
|
|
}
|
|
|
|
// topUpQueryWindowSeconds 限制充值记录查询的时间窗口(秒)。
|
|
const topUpQueryWindowSeconds int64 = 30 * 24 * 60 * 60
|
|
|
|
// topUpQueryCutoff 返回允许查询的最早 create_time(秒级 Unix 时间戳)。
|
|
func topUpQueryCutoff() int64 {
|
|
return common.GetTimestamp() - topUpQueryWindowSeconds
|
|
}
|
|
|
|
func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
|
// Start transaction
|
|
tx := DB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, 0, tx.Error
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
cutoff := topUpQueryCutoff()
|
|
|
|
// Get total count within transaction
|
|
err = tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, cutoff).Count(&total).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated topups within same transaction
|
|
err = tx.Where("user_id = ? AND create_time >= ?", userId, cutoff).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Commit transaction
|
|
if err = tx.Commit().Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return topups, total, nil
|
|
}
|
|
|
|
// GetAllTopUps 获取全平台的充值记录(管理员使用,不限制时间窗口)
|
|
func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
|
tx := DB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, 0, tx.Error
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err = tx.Commit().Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return topups, total, nil
|
|
}
|
|
|
|
// searchTopUpCountHardLimit 搜索充值记录时 COUNT 的安全上限,
|
|
// 防止对超大表执行无界 COUNT 触发 DoS。
|
|
const searchTopUpCountHardLimit = 10000
|
|
|
|
// SearchUserTopUps 按订单号搜索某用户的充值记录
|
|
func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
|
tx := DB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, 0, tx.Error
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
query := tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, topUpQueryCutoff())
|
|
if keyword != "" {
|
|
pattern, perr := sanitizeLikePattern(keyword)
|
|
if perr != nil {
|
|
tx.Rollback()
|
|
return nil, 0, perr
|
|
}
|
|
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
|
|
}
|
|
|
|
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
|
|
tx.Rollback()
|
|
common.SysError(common.LogText("failed to count search topups: %s", err.Error()))
|
|
return nil, 0, common.NewMessage("Failed to search top-up records")
|
|
}
|
|
|
|
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
|
tx.Rollback()
|
|
common.SysError(common.LogText("failed to search topups: %s", err.Error()))
|
|
return nil, 0, common.NewMessage("Failed to search top-up records")
|
|
}
|
|
|
|
if err = tx.Commit().Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return topups, total, nil
|
|
}
|
|
|
|
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用,不限制时间窗口)
|
|
func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
|
tx := DB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, 0, tx.Error
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
query := tx.Model(&TopUp{})
|
|
if keyword != "" {
|
|
pattern, perr := sanitizeLikePattern(keyword)
|
|
if perr != nil {
|
|
tx.Rollback()
|
|
return nil, 0, perr
|
|
}
|
|
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
|
|
}
|
|
|
|
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
|
|
tx.Rollback()
|
|
common.SysError(common.LogText("failed to count search topups: %s", err.Error()))
|
|
return nil, 0, common.NewMessage("Failed to search top-up records")
|
|
}
|
|
|
|
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
|
tx.Rollback()
|
|
common.SysError(common.LogText("failed to search topups: %s", err.Error()))
|
|
return nil, 0, common.NewMessage("Failed to search top-up records")
|
|
}
|
|
|
|
if err = tx.Commit().Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return topups, total, nil
|
|
}
|
|
|
|
// ManualCompleteTopUp 管理员手动完成订单并给用户充值
|
|
func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
|
if tradeNo == "" {
|
|
return common.NewMessage("Order number not provided")
|
|
}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
var userId int
|
|
var quotaToAdd int
|
|
var payMoney float64
|
|
var paymentMethod string
|
|
|
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
|
topUp := &TopUp{}
|
|
// 行级锁,避免并发补单
|
|
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
|
|
return common.NewMessage("Top-up order does not exist")
|
|
}
|
|
|
|
// 幂等处理:已成功直接返回
|
|
if topUp.Status == common.TopUpStatusSuccess {
|
|
return nil
|
|
}
|
|
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return common.NewMessage("Order is not pending payment and cannot be completed")
|
|
}
|
|
|
|
// 计算应充值额度:
|
|
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
|
|
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
|
|
var quotaErr error
|
|
if topUp.PaymentProvider == PaymentProviderStripe {
|
|
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
} else {
|
|
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
}
|
|
if quotaErr != nil || quotaToAdd <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
|
|
// 标记完成
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
if err := tx.Save(topUp).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 增加用户额度(立即写库,保持一致性)
|
|
if err := creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
userId = topUp.UserId
|
|
payMoney = topUp.Money
|
|
paymentMethod = topUp.PaymentMethod
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 事务外记录日志,避免阻塞
|
|
syncCreditUserQuotaCache(userId, quotaToAdd, common.LogText("manual topup"))
|
|
RecordTopupLog(userId, common.NewMessage("Administrator completed the order, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"quota": logger.FormatQuota(quotaToAdd), "amount": fmt.Sprintf("%f", payMoney)}), callerIp, paymentMethod, "admin")
|
|
return nil
|
|
}
|
|
func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
|
|
if referenceId == "" {
|
|
return errors.New("payment order number not provided")
|
|
}
|
|
|
|
var quota int
|
|
topUp := &TopUp{}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
err := lockForUpdate(tx).Where(refCol+" = ?", referenceId).First(topUp).Error
|
|
if err != nil {
|
|
return errors.New("top-up order not found")
|
|
}
|
|
|
|
if topUp.PaymentProvider != PaymentProviderCreem {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return errors.New("invalid top-up order status")
|
|
}
|
|
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
err = tx.Save(topUp).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Creem 直接使用 Amount 作为充值额度(整数)
|
|
quota, err = common.WalletQuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
|
if err != nil || quota <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
|
|
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
|
updateFields := map[string]any{}
|
|
|
|
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
|
if customerEmail != "" {
|
|
// 先检查用户当前邮箱是否为空
|
|
var user User
|
|
err = tx.Where("id = ?", topUp.UserId).First(&user).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 如果用户邮箱为空,则更新为支付时使用的邮箱
|
|
if user.Email == "" {
|
|
updateFields["email"] = customerEmail
|
|
}
|
|
}
|
|
|
|
return creditTopUpQuota(tx, topUp.UserId, quota, updateFields)
|
|
})
|
|
|
|
if err != nil {
|
|
common.SysError(common.LogText("creem topup failed: %s", err.Error()))
|
|
return errors.New("top-up failed")
|
|
}
|
|
syncCreditUserQuotaCache(topUp.UserId, quota, common.LogText("creem topup"))
|
|
|
|
RecordTopupLog(topUp.UserId, common.NewMessage("{{provider}} top-up succeeded, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"provider": "Creem", "quota": quota, "amount": fmt.Sprintf("%.2f", topUp.Money)}), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
|
|
|
|
return nil
|
|
}
|
|
|
|
func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
|
if tradeNo == "" {
|
|
return errors.New("payment order number not provided")
|
|
}
|
|
|
|
var quotaToAdd int
|
|
topUp := &TopUp{}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error
|
|
if err != nil {
|
|
return errors.New("top-up order not found")
|
|
}
|
|
|
|
if topUp.PaymentProvider != PaymentProviderWaffo {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
|
|
if topUp.Status == common.TopUpStatusSuccess {
|
|
return nil // 幂等:已成功直接返回
|
|
}
|
|
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return errors.New("invalid top-up order status")
|
|
}
|
|
|
|
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
if err != nil || quotaToAdd <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
if err := tx.Save(topUp).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
|
})
|
|
|
|
if err != nil {
|
|
common.SysError(common.LogText("waffo topup failed: %s", err.Error()))
|
|
return errors.New("top-up failed")
|
|
}
|
|
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, common.LogText("waffo topup"))
|
|
|
|
if quotaToAdd > 0 {
|
|
RecordTopupLog(topUp.UserId, common.NewMessage("{{provider}} top-up succeeded, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"provider": "Waffo", "quota": logger.FormatQuota(quotaToAdd), "amount": fmt.Sprintf("%.2f", topUp.Money)}), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func RechargeWaffoPancake(tradeNo string) (err error) {
|
|
if tradeNo == "" {
|
|
return errors.New("payment order number not provided")
|
|
}
|
|
|
|
var quotaToAdd int
|
|
topUp := &TopUp{}
|
|
|
|
refCol := "`trade_no`"
|
|
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
|
|
refCol = `"trade_no"`
|
|
}
|
|
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error
|
|
if err != nil {
|
|
return errors.New("top-up order not found")
|
|
}
|
|
|
|
if topUp.PaymentProvider != PaymentProviderWaffoPancake {
|
|
return ErrPaymentMethodMismatch
|
|
}
|
|
|
|
if topUp.Status == common.TopUpStatusSuccess {
|
|
return nil
|
|
}
|
|
|
|
if topUp.Status != common.TopUpStatusPending {
|
|
return errors.New("invalid top-up order status")
|
|
}
|
|
|
|
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
|
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
|
)
|
|
if err != nil || quotaToAdd <= 0 {
|
|
return ErrInvalidTopUpQuota
|
|
}
|
|
|
|
topUp.CompleteTime = common.GetTimestamp()
|
|
topUp.Status = common.TopUpStatusSuccess
|
|
if err := tx.Save(topUp).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
|
})
|
|
|
|
if err != nil {
|
|
common.SysError(common.LogText("waffo pancake topup failed: %s", err.Error()))
|
|
return errors.New("top-up failed")
|
|
}
|
|
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, common.LogText("waffo pancake topup"))
|
|
|
|
if quotaToAdd > 0 {
|
|
RecordLog(topUp.UserId, LogTypeTopup, common.NewMessage("{{provider}} top-up succeeded, amount added: {{quota}}, payment amount: {{amount}}", map[string]any{"provider": "Waffo Pancake", "quota": logger.FormatQuota(quotaToAdd), "amount": fmt.Sprintf("%.2f", topUp.Money)}))
|
|
}
|
|
|
|
return nil
|
|
}
|