new-api/service/rebate.go
zizi 6f134b1a83 feat: add affiliate rebate service
Implement rebate calculation, idempotent record creation, frozen release handling, and tests for rebate configuration behavior.
2026-05-20 14:03:16 +08:00

152 lines
3.8 KiB
Go

package service
import (
"errors"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func IsRebateEnabled() bool {
enabled, err := strconv.ParseBool(readRebateOption(model.AffRebateEnabledKey))
return err == nil && enabled
}
func GetGlobalRebateRatePercent() int {
return parseNonNegativeRebateInt(readRebateOption(model.AffRebateRatePercentKey))
}
func GetGlobalRebateFrozenDays() int {
return parseNonNegativeRebateInt(readRebateOption(model.AffRebateFrozenDaysKey))
}
func GetEffectiveRebateRate(inviter *model.User) int {
if inviter != nil && inviter.AffRebateRatePercent > 0 {
return inviter.AffRebateRatePercent
}
return GetGlobalRebateRatePercent()
}
func GetEffectiveFrozenDays(inviter *model.User) int {
if inviter != nil && inviter.AffRebateFrozenDays > 0 {
return inviter.AffRebateFrozenDays
}
return GetGlobalRebateFrozenDays()
}
func ProcessRebateAfterRecharge(invitee *model.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")
}
return model.DB.Transaction(func(tx *gorm.DB) error {
var inviter model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&inviter, "id = ?", invitee.InviterId).Error; err != nil {
return err
}
var existing model.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()
record := model.RebateRecord{
InviterId: inviter.Id,
InviteeId: invitee.Id,
OrderId: orderId,
OrderType: orderType,
OrderAmount: orderAmount,
RebateAmount: rebateAmount,
RatePercent: rate,
Status: "released",
ReleasedAt: &now,
}
if frozenDays := GetEffectiveFrozenDays(&inviter); 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
}
return tx.Model(&model.User{}).Where("id = ?", inviter.Id).Updates(map[string]interface{}{
"aff_quota": gorm.Expr("aff_quota + ?", rebateAmount),
"aff_history": gorm.Expr("aff_history + ?", rebateAmount),
}).Error
})
}
func ReleaseExpiredRebates() error {
return model.DB.Transaction(func(tx *gorm.DB) error {
var records []model.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(&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
}
}
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
}