new-api/model/rebate_process.go
zizi 36b742ad3d fix: harden payment and background edge cases
Acknowledge unrecoverable direct payment callback mismatches, tolerate missing affiliate users during rebate processing, and make channel monitor runner shutdown wait for checks safely.
2026-05-20 16:37:21 +08:00

188 lines
4.8 KiB
Go

package model
import (
"errors"
"log"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const RebateOrderTypeTopUp = "topup"
func IsRebateEnabled() bool {
enabled, err := strconv.ParseBool(readRebateOption(AffRebateEnabledKey))
return err == nil && enabled
}
func GetGlobalRebateRatePercent() int {
return parseNonNegativeRebateInt(readRebateOption(AffRebateRatePercentKey))
}
func GetGlobalRebateFrozenDays() int {
return parseNonNegativeRebateInt(readRebateOption(AffRebateFrozenDaysKey))
}
func GetEffectiveRebateRate(inviter *User) int {
if inviter != nil && inviter.AffRebateRatePercent > 0 {
return inviter.AffRebateRatePercent
}
return GetGlobalRebateRatePercent()
}
func GetEffectiveFrozenDays(inviter *User) int {
if inviter != nil && inviter.AffRebateFrozenDays > 0 {
return inviter.AffRebateFrozenDays
}
return GetGlobalRebateFrozenDays()
}
func ProcessRebateAfterRecharge(invitee *User, orderId int, orderType string, orderAmount int) error {
return DB.Transaction(func(tx *gorm.DB) error {
return processRebateAfterRechargeTx(tx, invitee, orderId, orderType, orderAmount)
})
}
func processTopUpRebateTx(tx *gorm.DB, topUp *TopUp, quotaToAdd int) error {
if topUp == nil || quotaToAdd <= 0 {
return nil
}
var invitee User
if err := tx.First(&invitee, "id = ?", topUp.UserId).Error; err != nil {
return err
}
return processRebateAfterRechargeTx(tx, &invitee, topUp.Id, RebateOrderTypeTopUp, quotaToAdd)
}
func processRebateAfterRechargeTx(tx *gorm.DB, invitee *User, orderId int, orderType string, orderAmount int) error {
if invitee == nil || invitee.InviterId <= 0 || orderAmount <= 0 {
return nil
}
if !IsRebateEnabled() {
return nil
}
if orderType == "" {
return errors.New("order type is empty")
}
if orderId <= 0 {
return errors.New("order id must be positive")
}
var inviter User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&inviter, "id = ?", invitee.InviterId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
var existing RebateRecord
err := tx.Where(
"order_type = ? AND order_id = ? AND inviter_id = ? AND invitee_id = ?",
orderType, orderId, inviter.Id, invitee.Id,
).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
rate := GetEffectiveRebateRate(&inviter)
if rate <= 0 {
return nil
}
rebateAmount := orderAmount * rate / 100
if rebateAmount <= 0 {
return nil
}
now := time.Now()
frozenDays := GetEffectiveFrozenDays(&inviter)
record := RebateRecord{
InviterId: inviter.Id,
InviteeId: invitee.Id,
OrderId: orderId,
OrderType: orderType,
OrderAmount: orderAmount,
RebateAmount: rebateAmount,
RatePercent: rate,
Status: "released",
ReleasedAt: &now,
}
if frozenDays > 0 {
frozenUntil := now.Add(time.Duration(frozenDays) * 24 * time.Hour)
record.Status = "frozen"
record.FrozenUntil = &frozenUntil
record.ReleasedAt = nil
}
if err := tx.Create(&record).Error; err != nil {
return err
}
updates := map[string]interface{}{
"aff_history": gorm.Expr("aff_history + ?", rebateAmount),
}
if frozenDays <= 0 {
updates["aff_quota"] = gorm.Expr("aff_quota + ?", rebateAmount)
}
return tx.Model(&User{}).Where("id = ?", inviter.Id).Updates(updates).Error
}
func ReleaseExpiredRebates() error {
return DB.Transaction(func(tx *gorm.DB) error {
var records []RebateRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ? AND frozen_until IS NOT NULL AND frozen_until <= ?", "frozen", time.Now()).
Order("id ASC").
Find(&records).Error; err != nil {
return err
}
releasedAt := time.Now()
for _, record := range records {
result := tx.Model(&RebateRecord{}).
Where("id = ? AND status = ?", record.Id, "frozen").
Updates(map[string]interface{}{
"status": "released",
"released_at": releasedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
continue
}
quotaResult := tx.Model(&User{}).Where("id = ?", record.InviterId).
Update("aff_quota", gorm.Expr("aff_quota + ?", record.RebateAmount))
if quotaResult.Error != nil {
return quotaResult.Error
}
if quotaResult.RowsAffected == 0 {
log.Printf("ReleaseExpiredRebates: inviter %d not found, rebate %d quota (%d) not delivered",
record.InviterId, record.Id, record.RebateAmount)
}
}
return nil
})
}
func readRebateOption(key string) string {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
return common.OptionMap[key]
}
func parseNonNegativeRebateInt(value string) int {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
return 0
}
return parsed
}