new-api/controller/affiliate.go
zizi 886d32f255 feat: add affiliate admin API
Add admin endpoints for affiliate overview, invites, rebates, transfers, user overrides, batch rate updates, and global rebate config.
2026-05-20 14:18:06 +08:00

335 lines
8.4 KiB
Go

package controller
import (
"errors"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
const (
maxAffiliateRatePercent = 100
maxAffiliateFrozenDays = 3650
maxAffiliateBatchUsers = 1000
)
type affiliateUserSettingsRequest struct {
AffRebateRatePercent int `json:"aff_rebate_rate_percent"`
AffRebateFrozenDays int `json:"aff_rebate_frozen_days"`
}
type affiliateBatchRateRequest struct {
UserIds []int `json:"user_ids"`
AffRebateRatePercent int `json:"aff_rebate_rate_percent"`
}
type affiliateConfigRequest struct {
AffRebateRatePercent *int `json:"aff_rebate_rate_percent"`
AffRebateFrozenDays *int `json:"aff_rebate_frozen_days"`
AffRebateEnabled *bool `json:"aff_rebate_enabled"`
}
func AdminGetAffiliateOverview(c *gin.Context) {
overview, err := model.GetAffiliateOverview()
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, overview)
}
func AdminGetAffiliateRebates(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
status := strings.TrimSpace(c.Query("status"))
if status != "" && status != "frozen" && status != "released" {
common.ApiErrorMsg(c, "status 只能是 frozen 或 released")
return
}
userId := 0
if userIdText := strings.TrimSpace(c.Query("user_id")); userIdText != "" {
parsed, err := strconv.Atoi(userIdText)
if err != nil || parsed <= 0 {
common.ApiErrorMsg(c, "user_id 参数格式不正确")
return
}
userId = parsed
}
records, total, err := model.GetRebateRecords(pageInfo.GetPage(), pageInfo.GetPageSize(), status, userId)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(records)
common.ApiSuccess(c, pageInfo)
}
func AdminGetAffiliateInvites(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userId, ok := parseOptionalPositiveQuery(c, "user_id")
if !ok {
return
}
startTimestamp, ok := parseOptionalTimestampQuery(c, "start_timestamp")
if !ok {
return
}
endTimestamp, ok := parseOptionalTimestampQuery(c, "end_timestamp")
if !ok {
return
}
invites, total, err := model.GetAffiliateInvites(pageInfo.GetPage(), pageInfo.GetPageSize(), userId, startTimestamp, endTimestamp, c.Query("keyword"))
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(invites)
common.ApiSuccess(c, pageInfo)
}
func AdminGetAffiliateTransfers(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userId, ok := parseOptionalPositiveQuery(c, "user_id")
if !ok {
return
}
startTimestamp, ok := parseOptionalTimestampQuery(c, "start_timestamp")
if !ok {
return
}
endTimestamp, ok := parseOptionalTimestampQuery(c, "end_timestamp")
if !ok {
return
}
logs, total, err := model.GetAffiliateTransferLogs(pageInfo.GetPage(), pageInfo.GetPageSize(), userId, startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
}
func AdminGetAffiliateUsers(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
users, total, err := model.GetAffiliateUsers(pageInfo.GetPage(), pageInfo.GetPageSize(), c.Query("keyword"))
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
common.ApiSuccess(c, pageInfo)
}
func AdminGetAffiliateUserOverview(c *gin.Context) {
userId, ok := getAffiliateUserIdParam(c)
if !ok {
return
}
overview, err := model.GetAffiliateUserOverview(userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, overview)
}
func AdminUpdateAffiliateUserSettings(c *gin.Context) {
userId, ok := getAffiliateUserIdParam(c)
if !ok {
return
}
var req affiliateUserSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if err := validateAffiliateRate(req.AffRebateRatePercent); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
if err := validateAffiliateFrozenDays(req.AffRebateFrozenDays); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
user, err := model.UpdateUserAffiliateSettings(userId, req.AffRebateRatePercent, req.AffRebateFrozenDays)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, user)
}
func AdminBatchUpdateAffiliateRate(c *gin.Context) {
var req affiliateBatchRateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if len(req.UserIds) == 0 {
common.ApiErrorMsg(c, "user_ids 不能为空")
return
}
if len(req.UserIds) > maxAffiliateBatchUsers {
common.ApiErrorMsg(c, "user_ids 一次最多 1000 个")
return
}
if err := validateAffiliateRate(req.AffRebateRatePercent); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
userIds := uniquePositiveUserIds(req.UserIds)
if len(userIds) != len(req.UserIds) {
common.ApiErrorMsg(c, "user_ids 必须是正整数且不能重复")
return
}
affected, err := model.BatchUpdateUserAffiliateRate(userIds, req.AffRebateRatePercent)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{"affected": affected})
}
func AdminClearAffiliateUserSettings(c *gin.Context) {
userId, ok := getAffiliateUserIdParam(c)
if !ok {
return
}
user, err := model.ClearUserAffiliateSettings(userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, user)
}
func AdminUpdateAffiliateConfig(c *gin.Context) {
var req affiliateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if req.AffRebateRatePercent != nil {
if err := validateAffiliateRate(*req.AffRebateRatePercent); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
if req.AffRebateFrozenDays != nil {
if err := validateAffiliateFrozenDays(*req.AffRebateFrozenDays); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
if req.AffRebateRatePercent != nil {
if err := model.UpdateOption(model.AffRebateRatePercentKey, strconv.Itoa(*req.AffRebateRatePercent)); err != nil {
common.ApiError(c, err)
return
}
}
if req.AffRebateFrozenDays != nil {
if err := model.UpdateOption(model.AffRebateFrozenDaysKey, strconv.Itoa(*req.AffRebateFrozenDays)); err != nil {
common.ApiError(c, err)
return
}
}
if req.AffRebateEnabled != nil {
if err := model.UpdateOption(model.AffRebateEnabledKey, strconv.FormatBool(*req.AffRebateEnabled)); err != nil {
common.ApiError(c, err)
return
}
}
common.ApiSuccess(c, getAffiliateConfigSnapshot())
}
func getAffiliateUserIdParam(c *gin.Context) (int, bool) {
userId, err := strconv.Atoi(c.Param("user_id"))
if err != nil || userId <= 0 {
common.ApiErrorMsg(c, "user_id 参数格式不正确")
return 0, false
}
return userId, true
}
func parseOptionalPositiveQuery(c *gin.Context, key string) (int, bool) {
value := strings.TrimSpace(c.Query(key))
if value == "" {
return 0, true
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
common.ApiErrorMsg(c, key+" 参数格式不正确")
return 0, false
}
return parsed, true
}
func parseOptionalTimestampQuery(c *gin.Context, key string) (int64, bool) {
value := strings.TrimSpace(c.Query(key))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
common.ApiErrorMsg(c, key+" 参数格式不正确")
return 0, false
}
return parsed, true
}
func validateAffiliateRate(rate int) error {
if rate < 0 || rate > maxAffiliateRatePercent {
return errors.New("返利比例必须在 0-100 之间")
}
return nil
}
func validateAffiliateFrozenDays(days int) error {
if days < 0 || days > maxAffiliateFrozenDays {
return errors.New("冻结天数必须在 0-3650 之间")
}
return nil
}
func uniquePositiveUserIds(userIds []int) []int {
seen := make(map[int]struct{}, len(userIds))
uniqueIds := make([]int, 0, len(userIds))
for _, userId := range userIds {
if userId <= 0 {
return uniqueIds
}
if _, ok := seen[userId]; ok {
return uniqueIds
}
seen[userId] = struct{}{}
uniqueIds = append(uniqueIds, userId)
}
return uniqueIds
}
func getAffiliateConfigSnapshot() gin.H {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
return gin.H{
"aff_rebate_rate_percent": common.OptionMap[model.AffRebateRatePercentKey],
"aff_rebate_frozen_days": common.OptionMap[model.AffRebateFrozenDaysKey],
"aff_rebate_enabled": common.OptionMap[model.AffRebateEnabledKey],
}
}