feat: add affiliate admin API

Add admin endpoints for affiliate overview, invites, rebates, transfers, user overrides, batch rate updates, and global rebate config.
This commit is contained in:
zizi 2026-05-20 14:18:06 +08:00
parent 6f134b1a83
commit 886d32f255
7 changed files with 837 additions and 18 deletions

334
controller/affiliate.go Normal file
View File

@ -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],
}
}

View File

@ -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, "邀请额度转入余额")
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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 为空!")

View File

@ -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())
{