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:
parent
6f134b1a83
commit
886d32f255
334
controller/affiliate.go
Normal file
334
controller/affiliate.go
Normal 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],
|
||||
}
|
||||
}
|
||||
238
controller/affiliate_test.go
Normal file
238
controller/affiliate_test.go
Normal 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, "邀请额度转入余额")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
63
model/log.go
63
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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
125
model/user.go
125
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 为空!")
|
||||
|
||||
@ -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())
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user