From 886d32f2556243dc635a90c2fbd2688bbb3e00f0 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 14:18:06 +0800 Subject: [PATCH] feat: add affiliate admin API Add admin endpoints for affiliate overview, invites, rebates, transfers, user overrides, batch rate updates, and global rebate config. --- controller/affiliate.go | 334 +++++++++++++++++++++++++++++++++++ controller/affiliate_test.go | 238 +++++++++++++++++++++++++ controller/user.go | 1 + model/log.go | 63 +++++-- model/rebate_record.go | 80 +++++++++ model/user.go | 125 +++++++++++++ router/api-router.go | 14 ++ 7 files changed, 837 insertions(+), 18 deletions(-) create mode 100644 controller/affiliate.go create mode 100644 controller/affiliate_test.go diff --git a/controller/affiliate.go b/controller/affiliate.go new file mode 100644 index 00000000..1f1ad140 --- /dev/null +++ b/controller/affiliate.go @@ -0,0 +1,334 @@ +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], + } +} diff --git a/controller/affiliate_test.go b/controller/affiliate_test.go new file mode 100644 index 00000000..e799161c --- /dev/null +++ b/controller/affiliate_test.go @@ -0,0 +1,238 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type affiliateAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +type affiliatePageResponse struct { + Total int `json:"total"` + Items json.RawMessage `json:"items"` +} + +func setupAffiliateControllerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + previousDB := model.DB + previousLogDB := model.LOG_DB + common.OptionMapRWMutex.RLock() + previousOptionMap := common.OptionMap + common.OptionMapRWMutex.RUnlock() + + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.RebateRecord{}, &model.Option{}, &model.Log{})) + + model.DB = db + model.LOG_DB = db + common.OptionMapRWMutex.Lock() + common.OptionMap = map[string]string{ + model.AffRebateRatePercentKey: "0", + model.AffRebateFrozenDaysKey: "0", + model.AffRebateEnabledKey: "false", + } + common.OptionMapRWMutex.Unlock() + + t.Cleanup(func() { + model.DB = previousDB + model.LOG_DB = previousLogDB + common.OptionMapRWMutex.Lock() + common.OptionMap = previousOptionMap + common.OptionMapRWMutex.Unlock() + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + + return db +} + +func performAffiliateRequest(t *testing.T, handler gin.HandlerFunc, method string, target string, body any, params ...gin.Param) *httptest.ResponseRecorder { + t.Helper() + + var requestBody *bytes.Reader + if body == nil { + requestBody = bytes.NewReader(nil) + } else { + bodyBytes, err := common.Marshal(body) + require.NoError(t, err) + requestBody = bytes.NewReader(bodyBytes) + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(method, target, requestBody) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Params = params + handler(ctx) + return recorder +} + +func decodeAffiliateAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) affiliateAPIResponse { + t.Helper() + + require.Equal(t, http.StatusOK, recorder.Code) + var payload affiliateAPIResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload)) + return payload +} + +func TestAdminUpdateAffiliateConfigRejectsNegativeValues(t *testing.T) { + setupAffiliateControllerTestDB(t) + + recorder := performAffiliateRequest(t, AdminUpdateAffiliateConfig, http.MethodPut, "/api/admin/affiliates/config", gin.H{ + "aff_rebate_rate_percent": -1, + }) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.False(t, payload.Success) + require.Contains(t, payload.Message, "比例") +} + +func TestAdminUpdateAffiliateConfigPersistsOptionMap(t *testing.T) { + setupAffiliateControllerTestDB(t) + + recorder := performAffiliateRequest(t, AdminUpdateAffiliateConfig, http.MethodPut, "/api/admin/affiliates/config", gin.H{ + "aff_rebate_rate_percent": 12, + "aff_rebate_frozen_days": 30, + "aff_rebate_enabled": true, + }) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.True(t, payload.Success) + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + require.Equal(t, "12", common.OptionMap[model.AffRebateRatePercentKey]) + require.Equal(t, "30", common.OptionMap[model.AffRebateFrozenDaysKey]) + require.Equal(t, "true", common.OptionMap[model.AffRebateEnabledKey]) +} + +func TestAdminBatchUpdateAffiliateRateRejectsTooManyUsers(t *testing.T) { + setupAffiliateControllerTestDB(t) + + userIds := make([]int, 1001) + for i := range userIds { + userIds[i] = i + 1 + } + recorder := performAffiliateRequest(t, AdminBatchUpdateAffiliateRate, http.MethodPost, "/api/admin/affiliates/users/batch-rate", gin.H{ + "user_ids": userIds, + "aff_rebate_rate_percent": 20, + }) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.False(t, payload.Success) + require.Contains(t, payload.Message, "1000") +} + +func TestAdminUpdateAffiliateUserSettingsPersistsFields(t *testing.T) { + db := setupAffiliateControllerTestDB(t) + require.NoError(t, db.Create(&model.User{ + Id: 11, + Username: "affiliate_admin_user", + Password: "password123", + Status: common.UserStatusEnabled, + }).Error) + + recorder := performAffiliateRequest(t, AdminUpdateAffiliateUserSettings, http.MethodPut, "/api/admin/affiliates/users/11", gin.H{ + "aff_rebate_rate_percent": 25, + "aff_rebate_frozen_days": 7, + }, gin.Param{Key: "user_id", Value: "11"}) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.True(t, payload.Success) + var user model.User + require.NoError(t, db.First(&user, 11).Error) + require.Equal(t, 25, user.AffRebateRatePercent) + require.Equal(t, 7, user.AffRebateFrozenDays) +} + +func TestAdminGetAffiliateInvitesReturnsInvitePairs(t *testing.T) { + db := setupAffiliateControllerTestDB(t) + require.NoError(t, db.Create(&model.User{ + Id: 21, + Username: "inviter_user", + Password: "password123", + Status: common.UserStatusEnabled, + AffCode: "aff21", + CreatedAt: 100, + }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 22, + Username: "invitee_user", + Password: "password123", + Status: common.UserStatusEnabled, + AffCode: "aff22", + InviterId: 21, + CreatedAt: 200, + }).Error) + + recorder := performAffiliateRequest(t, AdminGetAffiliateInvites, http.MethodGet, "/api/admin/affiliates/invites?user_id=21", nil) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.True(t, payload.Success) + var page affiliatePageResponse + require.NoError(t, common.Unmarshal(payload.Data, &page)) + require.Equal(t, 1, page.Total) + var invites []model.AffiliateInvite + require.NoError(t, common.Unmarshal(page.Items, &invites)) + require.Len(t, invites, 1) + require.Equal(t, 22, invites[0].Invitee.Id) + require.Equal(t, 21, invites[0].Inviter.Id) +} + +func TestAdminGetAffiliateTransfersReturnsTransferLogs(t *testing.T) { + db := setupAffiliateControllerTestDB(t) + require.NoError(t, db.Create(&model.User{ + Id: 31, + Username: "transfer_user", + Password: "password123", + Status: common.UserStatusEnabled, + AffCode: "aff31", + }).Error) + require.NoError(t, db.Create(&model.Log{ + UserId: 31, + Username: "transfer_user", + CreatedAt: 300, + Type: model.LogTypeSystem, + Content: "邀请额度转入余额 $1.00", + }).Error) + + recorder := performAffiliateRequest(t, AdminGetAffiliateTransfers, http.MethodGet, "/api/admin/affiliates/transfers?user_id=31", nil) + + payload := decodeAffiliateAPIResponse(t, recorder) + require.True(t, payload.Success) + var page affiliatePageResponse + require.NoError(t, common.Unmarshal(payload.Data, &page)) + require.Equal(t, 1, page.Total) + var logs []model.Log + require.NoError(t, common.Unmarshal(page.Items, &logs)) + require.Len(t, logs, 1) + require.Equal(t, 31, logs[0].UserId) + require.Contains(t, logs[0].Content, "邀请额度转入余额") +} diff --git a/controller/user.go b/controller/user.go index 044e02da..89d13e91 100644 --- a/controller/user.go +++ b/controller/user.go @@ -348,6 +348,7 @@ func TransferAffQuota(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()}) return } + model.RecordLog(id, model.LogTypeSystem, fmt.Sprintf("邀请额度转入余额 %s", logger.LogQuota(tran.Quota))) common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil) } diff --git a/model/log.go b/model/log.go index 8ec7807e..a30bcae1 100644 --- a/model/log.go +++ b/model/log.go @@ -18,24 +18,24 @@ import ( ) type Log struct { - Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"` - UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` - Type int `json:"type" gorm:"index:idx_created_at_type"` - Content string `json:"content"` - Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` - TokenName string `json:"token_name" gorm:"index;default:''"` - ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` - Quota int `json:"quota" gorm:"default:0"` - PromptTokens int `json:"prompt_tokens" gorm:"default:0"` - CompletionTokens int `json:"completion_tokens" gorm:"default:0"` - UseTime int `json:"use_time" gorm:"default:0"` - IsStream bool `json:"is_stream"` - ChannelId int `json:"channel" gorm:"index"` - ChannelName string `json:"channel_name" gorm:"->"` - TokenId int `json:"token_id" gorm:"default:0;index"` - Group string `json:"group" gorm:"index"` - Ip string `json:"ip" gorm:"index;default:''"` + Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"` + UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + ChannelId int `json:"channel" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"->"` + TokenId int `json:"token_id" gorm:"default:0;index"` + Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` @@ -382,6 +382,33 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName return logs, total, err } +func GetAffiliateTransferLogs(page int, pageSize int, userId int, startTimestamp int64, endTimestamp int64) ([]*Log, int64, error) { + var logs []*Log + var total int64 + query := LOG_DB.Model(&Log{}). + Where("logs.type = ? AND logs.content LIKE ?", LogTypeSystem, "%邀请额度转入余额%") + if userId > 0 { + query = query.Where("logs.user_id = ?", userId) + } + if startTimestamp > 0 { + query = query.Where("logs.created_at >= ?", startTimestamp) + } + if endTimestamp > 0 { + query = query.Where("logs.created_at <= ?", endTimestamp) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + err := query.Order("logs.id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&logs).Error + return logs, total, err +} + const logSearchCountLimit = 10000 func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { diff --git a/model/rebate_record.go b/model/rebate_record.go index 773cf1ce..03cf2270 100644 --- a/model/rebate_record.go +++ b/model/rebate_record.go @@ -59,3 +59,83 @@ func GetRebateRecords(page int, pageSize int, status string, userId int) ([]Reba err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error return records, total, err } + +type AffiliateOverview struct { + TotalInvites int64 `json:"total_invites"` + TotalRebates int64 `json:"total_rebates"` + TotalRebateAmount int64 `json:"total_rebate_amount"` + FrozenRebateAmount int64 `json:"frozen_rebate_amount"` + ReleasedRebateAmount int64 `json:"released_rebate_amount"` +} + +func GetAffiliateOverview() (AffiliateOverview, error) { + var overview AffiliateOverview + if err := DB.Model(&User{}).Where("inviter_id > 0").Count(&overview.TotalInvites).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}).Count(&overview.TotalRebates).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.TotalRebateAmount).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Where("status = ?", "frozen"). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.FrozenRebateAmount).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Where("status = ?", "released"). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.ReleasedRebateAmount).Error; err != nil { + return overview, err + } + return overview, nil +} + +type AffiliateUserOverview struct { + User *User `json:"user"` + InvitedUsers int64 `json:"invited_users"` + TotalRebates int64 `json:"total_rebates"` + TotalRebateAmount int64 `json:"total_rebate_amount"` + FrozenRebateAmount int64 `json:"frozen_rebate_amount"` + ReleasedRebateAmount int64 `json:"released_rebate_amount"` +} + +func GetAffiliateUserOverview(userId int) (AffiliateUserOverview, error) { + var overview AffiliateUserOverview + user, err := GetUserById(userId, false) + if err != nil { + return overview, err + } + overview.User = user + if err := DB.Model(&User{}).Where("inviter_id = ?", userId).Count(&overview.InvitedUsers).Error; err != nil { + return overview, err + } + baseQuery := DB.Model(&RebateRecord{}).Where("inviter_id = ?", userId) + if err := baseQuery.Count(&overview.TotalRebates).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Where("inviter_id = ?", userId). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.TotalRebateAmount).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Where("inviter_id = ? AND status = ?", userId, "frozen"). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.FrozenRebateAmount).Error; err != nil { + return overview, err + } + if err := DB.Model(&RebateRecord{}). + Where("inviter_id = ? AND status = ?", userId, "released"). + Select("COALESCE(SUM(rebate_amount), 0)"). + Scan(&overview.ReleasedRebateAmount).Error; err != nil { + return overview, err + } + return overview, nil +} diff --git a/model/user.go b/model/user.go index 85c69f8d..57a3016c 100644 --- a/model/user.go +++ b/model/user.go @@ -294,6 +294,131 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, return users, total, nil } +func GetAffiliateUsers(page int, pageSize int, keyword string) ([]*User, int64, error) { + var users []*User + var total int64 + query := DB.Model(&User{}) + keyword = strings.TrimSpace(keyword) + if keyword != "" { + likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + if keywordInt, err := strconv.Atoi(keyword); err == nil { + query = query.Where("id = ? OR "+likeCondition, keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } else { + query = query.Where(likeCondition, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + err := query.Omit("password").Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&users).Error + return users, total, err +} + +type AffiliateInvite struct { + Invitee *User `json:"invitee"` + Inviter *User `json:"inviter"` + CreatedAt int64 `json:"created_at"` +} + +func GetAffiliateInvites(page int, pageSize int, userId int, startTimestamp int64, endTimestamp int64, keyword string) ([]AffiliateInvite, int64, error) { + var invitees []*User + var total int64 + query := DB.Model(&User{}).Where("inviter_id > 0") + if userId > 0 { + query = query.Where("id = ? OR inviter_id = ?", userId, userId) + } + if startTimestamp > 0 { + query = query.Where("created_at >= ?", startTimestamp) + } + if endTimestamp > 0 { + query = query.Where("created_at <= ?", endTimestamp) + } + keyword = strings.TrimSpace(keyword) + if keyword != "" { + likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + if keywordInt, err := strconv.Atoi(keyword); err == nil { + query = query.Where("id = ? OR inviter_id = ? OR "+likeCondition, keywordInt, keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } else { + query = query.Where(likeCondition, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if err := query.Omit("password").Order("created_at desc, id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&invitees).Error; err != nil { + return nil, 0, err + } + + inviterIds := make([]int, 0, len(invitees)) + seen := make(map[int]struct{}, len(invitees)) + for _, invitee := range invitees { + if invitee.InviterId <= 0 { + continue + } + if _, ok := seen[invitee.InviterId]; ok { + continue + } + seen[invitee.InviterId] = struct{}{} + inviterIds = append(inviterIds, invitee.InviterId) + } + + inviterMap := make(map[int]*User, len(inviterIds)) + if len(inviterIds) > 0 { + var inviters []*User + if err := DB.Model(&User{}).Omit("password").Where("id IN ?", inviterIds).Find(&inviters).Error; err != nil { + return nil, 0, err + } + for _, inviter := range inviters { + inviterMap[inviter.Id] = inviter + } + } + + invites := make([]AffiliateInvite, 0, len(invitees)) + for _, invitee := range invitees { + invites = append(invites, AffiliateInvite{ + Invitee: invitee, + Inviter: inviterMap[invitee.InviterId], + CreatedAt: invitee.CreatedAt, + }) + } + return invites, total, nil +} + +func UpdateUserAffiliateSettings(userId int, ratePercent int, frozenDays int) (*User, error) { + if _, err := GetUserById(userId, false); err != nil { + return nil, err + } + result := DB.Model(&User{}).Where("id = ?", userId).Updates(map[string]interface{}{ + "aff_rebate_rate_percent": ratePercent, + "aff_rebate_frozen_days": frozenDays, + }) + if result.Error != nil { + return nil, result.Error + } + return GetUserById(userId, false) +} + +func BatchUpdateUserAffiliateRate(userIds []int, ratePercent int) (int64, error) { + result := DB.Model(&User{}).Where("id IN ?", userIds).Update("aff_rebate_rate_percent", ratePercent) + return result.RowsAffected, result.Error +} + +func ClearUserAffiliateSettings(userId int) (*User, error) { + return UpdateUserAffiliateSettings(userId, 0, 0) +} + func GetUserById(id int, selectAll bool) (*User, error) { if id == 0 { return nil, errors.New("id 为空!") diff --git a/router/api-router.go b/router/api-router.go index 0d923b5d..3fe2f7a9 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -315,6 +315,20 @@ func SetApiRouter(router *gin.Engine) { announcementAdminRoute.PUT("/:id", controller.UpdateAnnouncement) announcementAdminRoute.DELETE("/:id", controller.DeleteAnnouncement) } + affiliateAdminRoute := apiRouter.Group("/admin/affiliates") + affiliateAdminRoute.Use(middleware.AdminAuth()) + { + affiliateAdminRoute.GET("/overview", controller.AdminGetAffiliateOverview) + affiliateAdminRoute.GET("/invites", controller.AdminGetAffiliateInvites) + affiliateAdminRoute.GET("/rebates", controller.AdminGetAffiliateRebates) + affiliateAdminRoute.GET("/transfers", controller.AdminGetAffiliateTransfers) + affiliateAdminRoute.GET("/users", controller.AdminGetAffiliateUsers) + affiliateAdminRoute.GET("/users/:user_id/overview", controller.AdminGetAffiliateUserOverview) + affiliateAdminRoute.PUT("/users/:user_id", controller.AdminUpdateAffiliateUserSettings) + affiliateAdminRoute.POST("/users/batch-rate", controller.AdminBatchUpdateAffiliateRate) + affiliateAdminRoute.DELETE("/users/:user_id", controller.AdminClearAffiliateUserSettings) + affiliateAdminRoute.PUT("/config", controller.AdminUpdateAffiliateConfig) + } channelMonitorAdminRoute := apiRouter.Group("/admin/channel-monitors") channelMonitorAdminRoute.Use(middleware.AdminAuth()) {