Acknowledge unrecoverable direct payment callback mismatches, tolerate missing affiliate users during rebate processing, and make channel monitor runner shutdown wait for checks safely.
188 lines
4.8 KiB
Go
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
|
|
}
|