tokenFactory/model/topup.go

549 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package model
import (
"errors"
"fmt"
"strings"
"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"`
// Username 列表接口填充,关联 users.username仅 JSON 输出,不参与持久化(不使用 omitempty便于前端始终拿到字段
Username string `json:"username" gorm:"-"`
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)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
}
func (topUp *TopUp) Insert() error {
var err error
err = DB.Create(topUp).Error
return err
}
func (topUp *TopUp) Update() error {
var err error
err = DB.Save(topUp).Error
return err
}
// fillTopUpUsernamesWithDB 为充值记录批量填充关联用户名(管理员全平台列表与当前用户本人充值列表均使用)。
// 使用独立 Session 避免与同一事务上先查 top_ups 再查 users 时 GORM 语句状态串扰Unscoped 以包含已软删除用户,保证历史订单仍能显示用户名。
func fillTopUpUsernamesWithDB(db *gorm.DB, topups []*TopUp) error {
if len(topups) == 0 {
return nil
}
idSet := make(map[int]struct{})
for _, t := range topups {
if t != nil && t.UserId > 0 {
idSet[t.UserId] = struct{}{}
}
}
if len(idSet) == 0 {
return nil
}
ids := make([]int, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
type idName struct {
Id int `gorm:"column:id"`
Username string `gorm:"column:username"`
}
var rows []idName
// NewDB: 与 TopUp 查询复用同一 *gorm.DB 时,避免 Statement 残留导致用户表查询异常
q := db.Session(&gorm.Session{NewDB: true}).Unscoped().Model(&User{}).Select("id", "username").Where("id IN ?", ids)
if err := q.Scan(&rows).Error; err != nil {
return err
}
nameByID := make(map[int]string, len(rows))
for i := range rows {
nameByID[rows[i].Id] = rows[i].Username
}
for _, t := range topups {
if t != nil {
t.Username = nameByID[t.UserId]
}
}
return nil
}
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 Recharge(referenceId string, customerId string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota float64
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%d", logger.FormatQuota(int(quota)), topUp.Amount))
ApplyAffiliateTopupReward(topUp.UserId, int(quota))
return nil
}
// RechargeStripe 按 Stripe 回调完成充值,并在事务中校验渠道、金额和币种。
// 仅当订单为待支付且支付渠道为 stripe、回调金额与订单金额匹配、币种为 USD 时才会入账。
func RechargeStripe(referenceId string, customerId string, paidMoney float64, currency string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
if paidMoney <= 0 {
return errors.New("无效的支付金额")
}
if strings.ToUpper(strings.TrimSpace(currency)) != "USD" {
return errors.New("不支持的支付币种")
}
var quota float64
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
if topUp.PaymentMethod != "stripe" {
return fmt.Errorf("支付渠道不匹配: expect stripe, got %s", topUp.PaymentMethod)
}
expectedMoney := decimal.NewFromFloat(topUp.Money)
actualMoney := decimal.NewFromFloat(paidMoney)
diff := expectedMoney.Sub(actualMoney).Abs()
// 允许 1 美分误差,兼容支付平台与本地浮点换算的微小偏差
if diff.GreaterThan(decimal.NewFromFloat(0.01)) {
return fmt.Errorf("支付金额不匹配: expect %s, got %s", expectedMoney.StringFixed(2), actualMoney.StringFixed(2))
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if err != nil {
return err
}
return nil
})
if err != nil {
common.SysError(fmt.Sprintf("stripe topup verify failed, trade_no=%s, currency=%s, paid=%.2f, err=%s", referenceId, strings.ToUpper(strings.TrimSpace(currency)), paidMoney, err.Error()))
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%d", logger.FormatQuota(int(quota)), topUp.Amount))
ApplyAffiliateTopupReward(topUp.UserId, int(quota))
return nil
}
// topUpStatusFilterQuery 为充值列表查询追加 status 条件;空字符串或非法值不追加。
func topUpStatusFilterQuery(db *gorm.DB, status string) *gorm.DB {
s := strings.TrimSpace(strings.ToLower(status))
if s == "" {
return db
}
switch s {
case common.TopUpStatusPending, common.TopUpStatusSuccess, common.TopUpStatusFailed, common.TopUpStatusExpired:
return db.Where("status = ?", s)
default:
return db
}
}
// applyTopUpTradeNoLike 按订单号关键字模糊筛选 trade_nokeyword 为空时不追加条件。
func applyTopUpTradeNoLike(db *gorm.DB, keyword string) *gorm.DB {
kw := strings.TrimSpace(keyword)
if kw == "" {
return db
}
like := "%%" + kw + "%%"
return db.Where("trade_no LIKE ?", like)
}
// applyTopUpUsernameJoin 管理员全平台列表按用户名模糊筛选INNER JOIN users避免 IN 子查询在部分驱动下的兼容问题)。
func applyTopUpUsernameJoin(db *gorm.DB, usernameKeyword string) *gorm.DB {
u := strings.TrimSpace(usernameKeyword)
if u == "" {
return db
}
like := "%%" + u + "%%"
return db.Joins("INNER JOIN users ON users.id = top_ups.user_id AND users.username LIKE ?", like)
}
// GetUserTopUps 分页返回指定用户的充值订单statusFilter、tradeNoKeyword 为空时不对对应维度筛选。
func GetUserTopUps(userId int, pageInfo *common.PageInfo, statusFilter, tradeNoKeyword string) (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()
}
}()
userScope := func(db *gorm.DB) *gorm.DB {
q := db.Where("user_id = ?", userId)
q = topUpStatusFilterQuery(q, statusFilter)
q = applyTopUpTradeNoLike(q, tradeNoKeyword)
return q
}
// Get total count within transaction
err = tx.Model(&TopUp{}).Scopes(userScope).Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// Get paginated topups within same transaction
err = tx.Model(&TopUp{}).Scopes(userScope).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
if err = fillTopUpUsernamesWithDB(tx, topups); 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, statusFilter, tradeNoKeyword, usernameKeyword string) (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()
}
}()
allScope := func(db *gorm.DB) *gorm.DB {
q := topUpStatusFilterQuery(db, statusFilter)
q = applyTopUpTradeNoLike(q, tradeNoKeyword)
q = applyTopUpUsernameJoin(q, usernameKeyword)
return q
}
if err = tx.Model(&TopUp{}).Scopes(allScope).Count(&total).Error; err != nil {
tx.Rollback()
return nil, 0, err
}
if err = tx.Model(&TopUp{}).Scopes(allScope).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
return nil, 0, err
}
if err = fillTopUpUsernamesWithDB(tx, topups); err != nil {
tx.Rollback()
return nil, 0, err
}
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return topups, total, nil
}
// ManualCompleteTopUp 管理员手动完成订单并给用户充值。
// adminUsername 会写入使用日志详情,便于审计是谁执行了补单。
func ManualCompleteTopUp(tradeNo string, adminUsername string) error {
if tradeNo == "" {
return errors.New("未提供订单号")
}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
var userId int
var quotaToAdd int
var payMoney float64
err := DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
// 行级锁,避免并发补单
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return errors.New("充值订单不存在")
}
// 幂等处理:已成功直接返回
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("订单状态不是待支付,无法补单")
}
// 计算应充值额度:
// - Stripe 订单Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单如易支付Amount 为美元数量,* QuotaPerUnit
if topUp.PaymentMethod == "stripe" {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
} else {
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
}
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
// 标记完成
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
// 增加用户额度(立即写库,保持一致性)
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
userId = topUp.UserId
payMoney = topUp.Money
return nil
})
if err != nil {
return err
}
// 事务外记录日志,避免阻塞
if userId > 0 && quotaToAdd > 0 {
operatorPrefix := "管理员"
if adminUsername != "" {
operatorPrefix = adminUsername
}
RecordLog(userId, LogTypeTopup, fmt.Sprintf("%s管理员补单成功充值金额: %v支付金额%f", operatorPrefix, logger.FormatQuota(quotaToAdd), payMoney))
ApplyAffiliateTopupReward(userId, quotaToAdd)
}
return nil
}
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota int64
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
// Creem 直接使用 Amount 作为充值额度(整数)
quota = topUp.Amount
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
updateFields := map[string]interface{}{
"quota": gorm.Expr("quota + ?", quota),
}
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
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
}
}
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
if err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("creem topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功充值额度: %v支付金额%.2f", quota, topUp.Money))
ApplyAffiliateTopupReward(topUp.UserId, int(quota))
return nil
}
func RechargeWaffo(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status == common.TopUpStatusSuccess {
return nil // 幂等:已成功直接返回
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("waffo topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功充值额度: %v支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
ApplyAffiliateTopupReward(topUp.UserId, quotaToAdd)
}
return nil
}