diff --git a/common/constants.go b/common/constants.go index d6b4fb52284c..e4b283788808 100644 --- a/common/constants.go +++ b/common/constants.go @@ -124,6 +124,11 @@ var TelegramBotName = "" var QuotaForNewUser = 0 var QuotaForInviter = 0 var QuotaForInvitee = 0 + +// Referral commission settings (payment-based referral) +var ReferralCommissionEnabled = false // Enable commission when referred user recharges +var ReferralCommissionPercent = 10.0 // Percentage of recharge amount (0-100) +var ReferralCommissionMaxRecharges = 0 // Max recharges to give commission (0 = unlimited) var ChannelDisableThreshold = 5.0 var AutomaticDisableChannelEnabled = false var AutomaticEnableChannelEnabled = false diff --git a/controller/topup.go b/controller/topup.go index 390f53f7dce8..9d237ab18072 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -405,6 +405,9 @@ func EpayNotify(c *gin.Context) { } logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp))) model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay") + if err := model.CreditReferralCommission(topUp.UserId, topUp.Money, "epay", topUp.Id); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("返佣失败 user_id=%d topup_id=%d trade_no=%s payment_method=%s err=%v", topUp.UserId, topUp.Id, topUp.TradeNo, topUp.PaymentMethod, err)) + } } } else { logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) diff --git a/controller/user.go b/controller/user.go index b8be50f7d15e..6f694e10e7dc 100644 --- a/controller/user.go +++ b/controller/user.go @@ -485,6 +485,32 @@ func GetAffCode(c *gin.Context) { return } +func GetInvitedUsers(c *gin.Context) { + id := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + users, total, err := model.GetInvitedUsers(id, pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(users) + common.ApiSuccess(c, pageInfo) +} + +func GetReferralCommissions(c *gin.Context) { + id := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + commissions, total, err := model.GetUserReferralCommissions(id, pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(commissions) + common.ApiSuccess(c, pageInfo) +} + func GetSelf(c *gin.Context) { id := c.GetInt("id") userRole := c.GetInt("role") @@ -532,11 +558,13 @@ func buildSelfUserData(user *model.User) map[string]interface{} { "quota": user.Quota, "used_quota": user.UsedQuota, "request_count": user.RequestCount, - "aff_code": user.AffCode, - "aff_count": user.AffCount, - "aff_quota": user.AffQuota, - "aff_history_quota": user.AffHistoryQuota, - "inviter_id": user.InviterId, + "aff_code": user.AffCode, + "aff_count": user.AffCount, + "aff_quota": user.AffQuota, + "aff_history_quota": user.AffHistoryQuota, + "aff_commission_rate": effectiveCommissionRate(user.ReferralCommissionPercent), + "aff_commission_max_recharges": common.ReferralCommissionMaxRecharges, + "inviter_id": user.InviterId, "linux_do_id": user.LinuxDOId, "setting": user.Setting, "stripe_customer": user.StripeCustomer, @@ -545,6 +573,13 @@ func buildSelfUserData(user *model.User) map[string]interface{} { } } +func effectiveCommissionRate(perUser *float64) float64 { + if perUser != nil { + return *perUser + } + return common.ReferralCommissionPercent +} + // 计算用户权限的辅助函数 func calculateUserPermissions(userRole int) map[string]interface{} { permissions := map[string]interface{}{} diff --git a/model/channel.go b/model/channel.go index 1de876877559..2f5322d571f8 100644 --- a/model/channel.go +++ b/model/channel.go @@ -49,7 +49,7 @@ type Channel struct { Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` HeaderOverride *string `json:"header_override" gorm:"type:text"` - Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"` + Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"omitempty,max=255"` // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` diff --git a/model/main.go b/model/main.go index ac63d1f59c12..1ef340dfaa4a 100644 --- a/model/main.go +++ b/model/main.go @@ -302,6 +302,7 @@ func migrateDB() error { &SystemTaskLock{}, &CasbinRule{}, &AuthzRole{}, + &ReferralCommission{}, ) if err != nil { return err @@ -363,6 +364,7 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&ReferralCommission{}, "ReferralCommission"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/option.go b/model/option.go index 89a233ec57c3..b373ad8176c5 100644 --- a/model/option.go +++ b/model/option.go @@ -133,6 +133,9 @@ func InitOptionMap() { common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser) common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter) common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee) + common.OptionMap["ReferralCommissionEnabled"] = strconv.FormatBool(common.ReferralCommissionEnabled) + common.OptionMap["ReferralCommissionPercent"] = strconv.FormatFloat(common.ReferralCommissionPercent, 'f', -1, 64) + common.OptionMap["ReferralCommissionMaxRecharges"] = strconv.Itoa(common.ReferralCommissionMaxRecharges) common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold) common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota) common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount) @@ -370,6 +373,8 @@ func updateOptionMap(key string, value string) (err error) { setting.DefaultUseAutoGroup = boolValue case "ExposeRatioEnabled": ratio_setting.SetExposeRatioEnabled(boolValue) + case "ReferralCommissionEnabled": + common.ReferralCommissionEnabled = boolValue } } switch key { @@ -512,6 +517,14 @@ func updateOptionMap(key string, value string) (err error) { common.QuotaForInviter, _ = strconv.Atoi(value) case "QuotaForInvitee": common.QuotaForInvitee, _ = strconv.Atoi(value) + case "ReferralCommissionPercent": + if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 && v <= 100 { + common.ReferralCommissionPercent = v + } + case "ReferralCommissionMaxRecharges": + if v, parseErr := strconv.Atoi(value); parseErr == nil && v >= 0 { + common.ReferralCommissionMaxRecharges = v + } case "QuotaRemindThreshold": common.QuotaRemindThreshold, _ = strconv.Atoi(value) case "PreConsumedQuota": diff --git a/model/referral_commission.go b/model/referral_commission.go new file mode 100644 index 000000000000..ccc88788bd34 --- /dev/null +++ b/model/referral_commission.go @@ -0,0 +1,72 @@ +package model + +import "github.com/QuantumNous/new-api/common" + +type ReferralCommission struct { + Id int `json:"id" gorm:"primaryKey"` + InviterId int `json:"inviter_id" gorm:"index"` + InviteeId int `json:"invitee_id" gorm:"index;uniqueIndex:idx_invitee_topup_method"` + TopUpId int `json:"top_up_id" gorm:"uniqueIndex:idx_invitee_topup_method"` + RechargeAmount float64 `json:"recharge_amount"` + CommissionQuota int `json:"commission_quota"` + CommissionRate float64 `json:"commission_rate"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50);uniqueIndex:idx_invitee_topup_method"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` +} + +type ReferralCommissionWithUser struct { + ReferralCommission + InviteeUsername string `json:"invitee_username"` +} + +type InvitedUser struct { + Id int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Status int `json:"status"` + CommissionCount int `json:"commission_count"` + TotalEarned float64 `json:"total_earned"` +} + +func GetInvitedUsers(inviterId int, pageInfo *common.PageInfo) ([]*InvitedUser, int64, error) { + var total int64 + var users []*InvitedUser + + countQuery := DB.Table("users").Where("inviter_id = ?", inviterId) + if err := countQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := DB.Table("users"). + Select("users.id, users.username, users.display_name, users.status, "+ + "COALESCE(rc.commission_count, 0) as commission_count, "+ + "COALESCE(rc.total_earned, 0) as total_earned"). + Joins("LEFT JOIN (SELECT invitee_id, COUNT(*) as commission_count, SUM(commission_quota) as total_earned "+ + "FROM referral_commissions WHERE inviter_id = ? GROUP BY invitee_id) rc ON rc.invitee_id = users.id", inviterId). + Where("users.inviter_id = ?", inviterId). + Order("users.id desc"). + Limit(pageInfo.GetPageSize()). + Offset(pageInfo.GetStartIdx()). + Find(&users).Error + return users, total, err +} + +func GetUserReferralCommissions(inviterId int, pageInfo *common.PageInfo) ([]*ReferralCommissionWithUser, int64, error) { + var total int64 + var commissions []*ReferralCommissionWithUser + + query := DB.Table("referral_commissions"). + Select("referral_commissions.*, users.username as invitee_username"). + Joins("LEFT JOIN users ON users.id = referral_commissions.invitee_id"). + Where("referral_commissions.inviter_id = ?", inviterId) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Order("referral_commissions.id desc"). + Limit(pageInfo.GetPageSize()). + Offset(pageInfo.GetStartIdx()). + Find(&commissions).Error + return commissions, total, err +} diff --git a/model/subscription.go b/model/subscription.go index 497fea147a80..6d77f5beba93 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -578,6 +578,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP var logPlanTitle string var logMoney float64 var logPaymentMethod string + var logOrderId int var upgradeGroup string err := DB.Transaction(func(tx *gorm.DB) error { var order SubscriptionOrder @@ -625,6 +626,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP logPlanTitle = plan.Title logMoney = order.Money logPaymentMethod = order.PaymentMethod + logOrderId = order.Id return nil }) if err != nil { @@ -636,6 +638,12 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP if logUserId > 0 { msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod) RecordLog(logUserId, LogTypeTopup, msg) + + // Credit referral commission to inviter (if enabled) + if err := CreditReferralCommission(logUserId, logMoney, logPaymentMethod, logOrderId); err != nil { + common.SysLog(fmt.Sprintf("返佣失败 user_id=%d topup_id=%d payment_method=%s err=%v", + logUserId, logOrderId, logPaymentMethod, err)) + } } return nil } diff --git a/model/topup.go b/model/topup.go index 92cb276b2e5c..73ae59393cdb 100644 --- a/model/topup.go +++ b/model/topup.go @@ -156,6 +156,12 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe) + // Credit referral commission to inviter (if enabled) + if err := CreditReferralCommission(topUp.UserId, topUp.Money, "stripe", topUp.Id); err != nil { + common.SysLog(fmt.Sprintf("返佣失败 user_id=%d topup_id=%d trade_no=%s payment_method=stripe err=%v", + topUp.UserId, topUp.Id, topUp.TradeNo, err)) + } + return nil } @@ -331,6 +337,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { var quotaToAdd int var payMoney float64 var paymentMethod string + var topUpId int err := DB.Transaction(func(tx *gorm.DB) error { topUp := &TopUp{} @@ -378,6 +385,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { userId = topUp.UserId payMoney = topUp.Money paymentMethod = topUp.PaymentMethod + topUpId = topUp.Id return nil }) @@ -387,8 +395,14 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { // 事务外记录日志,避免阻塞 RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin") + + if err := CreditReferralCommission(userId, payMoney, "manual", topUpId); err != nil { + common.SysLog(fmt.Sprintf("返佣失败 user_id=%d topup_id=%d payment_method=manual err=%v", userId, topUpId, err)) + } + return nil } + func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") @@ -461,6 +475,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem) + // Credit referral commission to inviter (if enabled) + if err := CreditReferralCommission(topUp.UserId, topUp.Money, "creem", topUp.Id); err != nil { + common.SysLog(fmt.Sprintf("返佣失败 user_id=%d topup_id=%d trade_no=%s payment_method=creem err=%v", + topUp.UserId, topUp.Id, topUp.TradeNo, err)) + } + return nil } @@ -585,5 +605,11 @@ func RechargeWaffoPancake(tradeNo string) (err error) { RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) } + // Credit referral commission to inviter (if enabled) + if err := CreditReferralCommission(topUp.UserId, topUp.Money, "waffo", topUp.Id); err != nil { + common.SysLog(fmt.Sprintf("返佣失败 user_id=%d topup_id=%d trade_no=%s payment_method=waffo err=%v", + topUp.UserId, topUp.Id, topUp.TradeNo, err)) + } + return nil } diff --git a/model/user.go b/model/user.go index 75531a26c960..c02003cbfd09 100644 --- a/model/user.go +++ b/model/user.go @@ -77,39 +77,40 @@ func resolveUserSortOptions(sortOptions []UserSortOptions) UserSortOptions { // User if you add sensitive fields, don't forget to clean them in setupLogin function. // Otherwise, the sensitive information will be saved on local storage in plain text! type User struct { - Id int `json:"id"` - Username string `json:"username" gorm:"unique;index" validate:"max=20"` - Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` - OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! - DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` - Role int `json:"role" gorm:"type:int;default:1"` // admin, common - Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled - Email string `json:"email" gorm:"index" validate:"max=50"` - GitHubId string `json:"github_id" gorm:"column:github_id;index"` - DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` - OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` - WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` - TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` - VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! - AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management - Quota int `json:"quota" gorm:"type:int;default:0"` - UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota - RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number - Group string `json:"group" gorm:"type:varchar(64);default:'default'"` - AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` - AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` - AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 - AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 - InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` - DeletedAt gorm.DeletedAt `gorm:"index"` - LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` - Setting string `json:"setting" gorm:"type:text;column:setting"` - Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` - StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` - CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` - LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` - AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"` - AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index" validate:"max=20"` + Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` + OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! + DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` + Role int `json:"role" gorm:"type:int;default:1"` // admin, common + Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled + Email string `json:"email" gorm:"index" validate:"max=50"` + GitHubId string `json:"github_id" gorm:"column:github_id;index"` + DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` + OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` + WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + Quota int `json:"quota" gorm:"type:int;default:0"` + UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` + AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 + AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 + InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + ReferralCommissionPercent *float64 `json:"referral_commission_percent" gorm:"type:decimal(5,2);column:referral_commission_percent"` // nil = use global default + DeletedAt gorm.DeletedAt `gorm:"index"` + LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"omitempty,max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` + AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"` + AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` } func (user *User) ToBaseUser() *UserBase { @@ -500,6 +501,96 @@ func inviteUser(inviterId int) (err error) { return DB.Save(user).Error } +// CreditReferralCommission credits the inviter with a commission when the referred user recharges +// This implements payment-based referral rewards instead of instant registration bonuses +func CreditReferralCommission(userId int, rechargeAmount float64, paymentMethod string, topUpId int) error { + if !common.ReferralCommissionEnabled || rechargeAmount <= 0 { + return nil + } + + user, err := GetUserById(userId, true) + if err != nil || user.InviterId == 0 { + return err + } + + // Per-inviter rate override: use inviter's custom rate if set, otherwise fall back to global + inviter, err := GetUserById(user.InviterId, true) + if err != nil { + return err + } + + rate := common.ReferralCommissionPercent + if inviter.ReferralCommissionPercent != nil { + rate = *inviter.ReferralCommissionPercent + } + if rate <= 0 || rate > 100 { + return nil + } + + commission := int(rechargeAmount * (rate / 100) * common.QuotaPerUnit) + if commission <= 0 { + return nil + } + + // Wrap count check, commission insert, and quota update in a single transaction + // to prevent race conditions from concurrent recharges + credited := false + err = DB.Transaction(func(tx *gorm.DB) error { + // Check max commission count within the transaction + if common.ReferralCommissionMaxRecharges > 0 { + var count int64 + if err := tx.Model(&ReferralCommission{}).Where("invitee_id = ?", userId).Count(&count).Error; err != nil { + return err + } + if int(count) >= common.ReferralCommissionMaxRecharges { + return nil + } + } + + // Idempotency: skip if this topup already credited a commission for this invitee + var existing int64 + if err := tx.Model(&ReferralCommission{}).Where("invitee_id = ? AND top_up_id = ? AND payment_method = ?", userId, topUpId, paymentMethod).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return nil + } + + // Record commission event for full audit trail + if err := tx.Create(&ReferralCommission{ + InviterId: user.InviterId, + InviteeId: userId, + TopUpId: topUpId, + RechargeAmount: rechargeAmount, + CommissionQuota: commission, + CommissionRate: rate, + PaymentMethod: paymentMethod, + }).Error; err != nil { + return err + } + + // Atomically update inviter's aff_quota + if err := tx.Model(&User{}).Where("id = ?", user.InviterId).Updates(map[string]interface{}{ + "aff_quota": gorm.Expr("aff_quota + ?", commission), + "aff_history": gorm.Expr("aff_history + ?", commission), + }).Error; err != nil { + return err + } + + credited = true + return nil + }) + + if err != nil { + return err + } + + if credited { + RecordLog(user.InviterId, LogTypeSystem, fmt.Sprintf("邀请用户充值返佣 %s (%.1f%% of $%.2f)", logger.LogQuota(commission), rate, rechargeAmount)) + } + return nil +} + func (user *User) TransferAffQuotaToQuota(quota int) error { // 检查quota是否小于最小额度 if float64(quota) < common.QuotaPerUnit { @@ -634,14 +725,16 @@ func (user *User) finishInsert(inviterId int) { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() { - if common.QuotaForInvitee > 0 { - _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) - RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) - } - if common.QuotaForInviter > 0 { - //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) - RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) - _ = inviteUser(inviterId) + // Skip legacy flat bonuses when the percentage-based commission system is active + if !common.ReferralCommissionEnabled { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + _ = inviteUser(inviterId) + } } } } @@ -691,13 +784,16 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() { - if common.QuotaForInvitee > 0 { - _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) - RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) - } - if common.QuotaForInviter > 0 { - RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) - _ = inviteUser(inviterId) + // Skip legacy flat bonuses when the percentage-based commission system is active + if !common.ReferralCommissionEnabled { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + _ = inviteUser(inviterId) + } } } } @@ -784,11 +880,17 @@ func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { } newUser := *user + if newUser.ReferralCommissionPercent != nil { + if *newUser.ReferralCommissionPercent < 0 || *newUser.ReferralCommissionPercent > 100 { + return fmt.Errorf("referral_commission_percent must be between 0 and 100") + } + } updates := map[string]interface{}{ - "username": newUser.Username, - "display_name": newUser.DisplayName, - "group": newUser.Group, - "remark": newUser.Remark, + "username": newUser.Username, + "display_name": newUser.DisplayName, + "group": newUser.Group, + "remark": newUser.Remark, + "referral_commission_percent": newUser.ReferralCommissionPercent, } if updatePassword { updates["password"] = newUser.Password diff --git a/router/api-router.go b/router/api-router.go index 80fd65178c44..4cb885a1ee49 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -98,6 +98,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/passkey/verify/finish", middleware.DisableCache(), controller.PasskeyVerifyFinish) selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/aff/invitees", controller.GetInvitedUsers) + selfRoute.GET("/aff/commissions", controller.GetReferralCommissions) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) diff --git a/web/src/features/users/components/users-mutate-drawer.tsx b/web/src/features/users/components/users-mutate-drawer.tsx index 8409f5721d64..5de302f94cc8 100644 --- a/web/src/features/users/components/users-mutate-drawer.tsx +++ b/web/src/features/users/components/users-mutate-drawer.tsx @@ -447,6 +447,47 @@ export function UsersMutateDrawer({ )} /> + + ( + + + {t('Referral commission override (%)')} + + + { + const raw = event.target.value + if (raw === '') { + field.onChange(null) + } else { + const parsed = Number(raw) + field.onChange( + Number.isFinite(parsed) ? parsed : null + ) + } + }} + name={field.name} + onBlur={field.onBlur} + ref={field.ref} + /> + + + + )} + /> )} diff --git a/web/src/features/users/lib/user-form.ts b/web/src/features/users/lib/user-form.ts index bc6c7894acb3..0f148c07b831 100644 --- a/web/src/features/users/lib/user-form.ts +++ b/web/src/features/users/lib/user-form.ts @@ -44,6 +44,12 @@ export const userFormSchema = z.object({ admin_permissions: z .record(z.string(), z.record(z.string(), z.boolean())) .optional(), + referral_commission_percent: z + .number() + .min(0) + .max(100) + .nullable() + .optional(), }) export type UserFormValues = z.infer @@ -62,6 +68,7 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = { remark: '', // Filled against the backend catalog at render time; see UsersMutateDrawer. admin_permissions: {}, + referral_commission_percent: null, } // ============================================================================ @@ -101,6 +108,8 @@ export function transformFormDataToPayload( // For update: quota is adjusted atomically via /api/user/manage, not sent here payload.group = data.group payload.remark = data.remark || undefined + payload.referral_commission_percent = + data.referral_commission_percent ?? null payload.id = userId } @@ -122,5 +131,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues { group: user.group || DEFAULT_GROUP, remark: user.remark || '', admin_permissions: user.admin_permissions ?? {}, + referral_commission_percent: user.referral_commission_percent ?? null, } } diff --git a/web/src/features/users/types.ts b/web/src/features/users/types.ts index ac73c975a7a1..197fbe4dd287 100644 --- a/web/src/features/users/types.ts +++ b/web/src/features/users/types.ts @@ -62,6 +62,7 @@ export const userSchema = z.object({ admin_permissions: z .record(z.string(), z.record(z.string(), z.boolean())) .optional(), + referral_commission_percent: z.number().nullable().optional(), }) export type User = z.infer @@ -126,6 +127,7 @@ export interface UserFormData { group?: string // Only used when updating user remark?: string // Only used when updating user admin_permissions?: AdminPermissionMatrix + referral_commission_percent?: number | null // Only used when updating user; null = use global default } export type ManageUserAction =