diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index d0b1d3af..f767bbea 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -70,7 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
affiliateRepository := repository.NewAffiliateRepository(client, db)
- affiliateService := service.NewAffiliateService(affiliateRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCacheService)
+ affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
@@ -231,7 +231,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
- adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler)
+ affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
+ adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go
new file mode 100644
index 00000000..97e649ec
--- /dev/null
+++ b/backend/internal/handler/admin/affiliate_handler.go
@@ -0,0 +1,183 @@
+package admin
+
+import (
+ "strconv"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AffiliateHandler handles admin affiliate (邀请返利) management:
+// listing users with custom settings, updating per-user invite codes
+// and exclusive rebate rates, and batch operations.
+type AffiliateHandler struct {
+ affiliateService *service.AffiliateService
+ adminService service.AdminService
+}
+
+// NewAffiliateHandler creates a new admin affiliate handler.
+func NewAffiliateHandler(affiliateService *service.AffiliateService, adminService service.AdminService) *AffiliateHandler {
+ return &AffiliateHandler{
+ affiliateService: affiliateService,
+ adminService: adminService,
+ }
+}
+
+// ListUsers returns paginated users with custom affiliate settings.
+// GET /api/v1/admin/affiliates/users
+func (h *AffiliateHandler) ListUsers(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ search := c.Query("search")
+
+ entries, total, err := h.affiliateService.AdminListCustomUsers(c.Request.Context(), service.AffiliateAdminFilter{
+ Search: search,
+ Page: page,
+ PageSize: pageSize,
+ })
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, entries, total, page, pageSize)
+}
+
+// UpdateUserSettings updates a user's affiliate settings.
+// PUT /api/v1/admin/affiliates/users/:user_id
+//
+// Both fields are optional and applied independently.
+type UpdateAffiliateUserRequest struct {
+ AffCode *string `json:"aff_code"`
+ AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
+ // ClearRebateRate explicitly clears the per-user rate (sets it to NULL).
+ // Used to disambiguate from "field not provided".
+ ClearRebateRate bool `json:"clear_rebate_rate"`
+}
+
+func (h *AffiliateHandler) UpdateUserSettings(c *gin.Context) {
+ userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
+ if err != nil || userID <= 0 {
+ response.BadRequest(c, "Invalid user_id")
+ return
+ }
+
+ var req UpdateAffiliateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ if req.AffCode != nil {
+ if err := h.affiliateService.AdminUpdateUserAffCode(c.Request.Context(), userID, *req.AffCode); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ }
+
+ if req.ClearRebateRate {
+ if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ } else if req.AffRebateRatePercent != nil {
+ if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, req.AffRebateRatePercent); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ }
+
+ response.Success(c, gin.H{"user_id": userID})
+}
+
+// ClearUserSettings removes ALL of a user's custom affiliate settings — clears
+// the exclusive rebate rate AND regenerates the invite code as a new system
+// random one. Conceptually this "removes the user from the custom list".
+//
+// Both writes happen in this handler; failure of one leaves the other applied,
+// but the operation is idempotent so the admin can re-run it safely.
+// DELETE /api/v1/admin/affiliates/users/:user_id
+func (h *AffiliateHandler) ClearUserSettings(c *gin.Context) {
+ userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
+ if err != nil || userID <= 0 {
+ response.BadRequest(c, "Invalid user_id")
+ return
+ }
+ if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if _, err := h.affiliateService.AdminResetUserAffCode(c.Request.Context(), userID); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, gin.H{"user_id": userID})
+}
+
+// BatchSetRate applies the same rebate rate (or clears it) to multiple users.
+//
+// Protocol: pass `clear: true` to clear rates (aff_rebate_rate_percent is
+// ignored). Otherwise aff_rebate_rate_percent is required and applied to
+// every user_id. The explicit `clear` flag exists because Go's JSON unmarshal
+// can't distinguish a missing field from `null`, and a silent clear from a
+// frontend that forgot to include the rate would be a footgun.
+//
+// POST /api/v1/admin/affiliates/users/batch-rate
+type BatchSetRateRequest struct {
+ UserIDs []int64 `json:"user_ids" binding:"required"`
+ AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
+ Clear bool `json:"clear"`
+}
+
+func (h *AffiliateHandler) BatchSetRate(c *gin.Context) {
+ var req BatchSetRateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ if len(req.UserIDs) == 0 {
+ response.BadRequest(c, "user_ids cannot be empty")
+ return
+ }
+ if !req.Clear && req.AffRebateRatePercent == nil {
+ response.BadRequest(c, "aff_rebate_rate_percent is required unless clear=true")
+ return
+ }
+ rate := req.AffRebateRatePercent
+ if req.Clear {
+ rate = nil
+ }
+ if err := h.affiliateService.AdminBatchSetUserRebateRate(c.Request.Context(), req.UserIDs, rate); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, gin.H{"affected": len(req.UserIDs)})
+}
+
+// AffiliateUserSummary is the minimal user shape returned by LookupUsers,
+// shared with the frontend's add-custom-user picker.
+type AffiliateUserSummary struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+}
+
+// LookupUsers searches users by email/username for the "add custom user" modal.
+// GET /api/v1/admin/affiliates/users/lookup?q=
+func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
+ keyword := c.Query("q")
+ if keyword == "" {
+ response.Success(c, []AffiliateUserSummary{})
+ return
+ }
+ users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 20, service.UserListFilters{Search: keyword}, "email", "asc")
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ result := make([]AffiliateUserSummary, len(users))
+ for i, u := range users {
+ result[i] = AffiliateUserSummary{ID: u.ID, Email: u.Email, Username: u.Username}
+ }
+ response.Success(c, result)
+}
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 2d4dcb5b..40bf1c69 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -242,6 +242,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
+
+ AffiliateEnabled: settings.AffiliateEnabled,
}
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
}
@@ -441,6 +443,9 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
+
+ // Affiliate (邀请返利) feature switch
+ AffiliateEnabled *bool `json:"affiliate_enabled"`
}
// UpdateSettings 更新系统设置
@@ -1265,6 +1270,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
+ AffiliateEnabled: func() bool {
+ if req.AffiliateEnabled != nil {
+ return *req.AffiliateEnabled
+ }
+ return previousSettings.AffiliateEnabled
+ }(),
}
authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1502,6 +1513,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
+
+ AffiliateEnabled: updatedSettings.AffiliateEnabled,
}
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
}
@@ -1870,6 +1883,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
+ if before.AffiliateEnabled != after.AffiliateEnabled {
+ changed = append(changed, "affiliate_enabled")
+ }
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed
}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 86074df7..051fab18 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -192,6 +192,9 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+
+ // Affiliate (邀请返利) feature switch
+ AffiliateEnabled bool `json:"affiliate_enabled"`
}
type DefaultSubscriptionSetting struct {
@@ -244,6 +247,8 @@ type PublicSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+
+ AffiliateEnabled bool `json:"affiliate_enabled"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO
diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go
index aee9d927..13e3ac88 100644
--- a/backend/internal/handler/handler.go
+++ b/backend/internal/handler/handler.go
@@ -34,6 +34,7 @@ type AdminHandlers struct {
ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
Payment *admin.PaymentHandler
+ Affiliate *admin.AffiliateHandler
}
// Handlers contains all HTTP handlers
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 96964de4..22f2aa15 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -75,5 +75,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
+
+ AffiliateEnabled: settings.AffiliateEnabled,
})
}
diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go
index 6d175488..a8725875 100644
--- a/backend/internal/handler/wire.go
+++ b/backend/internal/handler/wire.go
@@ -37,6 +37,7 @@ func ProvideAdminHandlers(
channelMonitorHandler *admin.ChannelMonitorHandler,
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
paymentHandler *admin.PaymentHandler,
+ affiliateHandler *admin.AffiliateHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
@@ -67,6 +68,7 @@ func ProvideAdminHandlers(
ChannelMonitor: channelMonitorHandler,
ChannelMonitorTemplate: channelMonitorTemplateHandler,
Payment: paymentHandler,
+ Affiliate: affiliateHandler,
}
}
@@ -169,6 +171,7 @@ var ProviderSet = wire.NewSet(
admin.NewChannelMonitorHandler,
admin.NewChannelMonitorRequestTemplateHandler,
admin.NewPaymentHandler,
+ admin.NewAffiliateHandler,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers,
diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go
index 342ddf4f..e3dd56b8 100644
--- a/backend/internal/repository/affiliate_repo.go
+++ b/backend/internal/repository/affiliate_repo.go
@@ -294,6 +294,8 @@ func queryAffiliateByUserID(ctx context.Context, client affiliateQueryExecer, us
rows, err := client.QueryContext(ctx, `
SELECT user_id,
aff_code,
+ aff_code_custom,
+ aff_rebate_rate_percent,
inviter_id,
aff_count,
aff_quota::double precision,
@@ -315,9 +317,12 @@ WHERE user_id = $1`, userID)
var out service.AffiliateSummary
var inviterID sql.NullInt64
+ var rebateRate sql.NullFloat64
if err := rows.Scan(
&out.UserID,
&out.AffCode,
+ &out.AffCodeCustom,
+ &rebateRate,
&inviterID,
&out.AffCount,
&out.AffQuota,
@@ -330,6 +335,10 @@ WHERE user_id = $1`, userID)
if inviterID.Valid {
out.InviterID = &inviterID.Int64
}
+ if rebateRate.Valid {
+ v := rebateRate.Float64
+ out.AffRebateRatePercent = &v
+ }
return &out, nil
}
@@ -337,6 +346,8 @@ func queryAffiliateByCode(ctx context.Context, client affiliateQueryExecer, code
rows, err := client.QueryContext(ctx, `
SELECT user_id,
aff_code,
+ aff_code_custom,
+ aff_rebate_rate_percent,
inviter_id,
aff_count,
aff_quota::double precision,
@@ -360,9 +371,12 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
var out service.AffiliateSummary
var inviterID sql.NullInt64
+ var rebateRate sql.NullFloat64
if err := rows.Scan(
&out.UserID,
&out.AffCode,
+ &out.AffCodeCustom,
+ &rebateRate,
&inviterID,
&out.AffCount,
&out.AffQuota,
@@ -375,6 +389,10 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
if inviterID.Valid {
out.InviterID = &inviterID.Int64
}
+ if rebateRate.Valid {
+ v := rebateRate.Float64
+ out.AffRebateRatePercent = &v
+ }
return &out, nil
}
@@ -418,3 +436,229 @@ func isAffiliateUniqueViolation(err error) bool {
}
return false
}
+
+// UpdateUserAffCode 改写用户的邀请码(自定义专属邀请码)。
+// 唯一性冲突返回 ErrAffiliateCodeTaken。
+func (r *affiliateRepository) UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error {
+ if userID <= 0 {
+ return service.ErrUserNotFound
+ }
+ code := strings.ToUpper(strings.TrimSpace(newCode))
+ if code == "" {
+ return service.ErrAffiliateCodeInvalid
+ }
+
+ return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
+ return err
+ }
+ res, err := txClient.ExecContext(txCtx, `
+UPDATE user_affiliates
+SET aff_code = $1,
+ aff_code_custom = true,
+ updated_at = NOW()
+WHERE user_id = $2`, code, userID)
+ if err != nil {
+ if isAffiliateUniqueViolation(err) {
+ return service.ErrAffiliateCodeTaken
+ }
+ return fmt.Errorf("update aff_code: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return service.ErrUserNotFound
+ }
+ return nil
+ })
+}
+
+// ResetUserAffCode 把 aff_code 还原为系统随机码,并清除 aff_code_custom 标记。
+func (r *affiliateRepository) ResetUserAffCode(ctx context.Context, userID int64) (string, error) {
+ if userID <= 0 {
+ return "", service.ErrUserNotFound
+ }
+ var newCode string
+ err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
+ return err
+ }
+ for i := 0; i < affiliateCodeMaxAttempts; i++ {
+ candidate, codeErr := generateAffiliateCode()
+ if codeErr != nil {
+ return codeErr
+ }
+ res, err := txClient.ExecContext(txCtx, `
+UPDATE user_affiliates
+SET aff_code = $1,
+ aff_code_custom = false,
+ updated_at = NOW()
+WHERE user_id = $2`, candidate, userID)
+ if err != nil {
+ if isAffiliateUniqueViolation(err) {
+ continue
+ }
+ return fmt.Errorf("reset aff_code: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return service.ErrUserNotFound
+ }
+ newCode = candidate
+ return nil
+ }
+ return fmt.Errorf("reset aff_code: exhausted attempts")
+ })
+ if err != nil {
+ return "", err
+ }
+ return newCode, nil
+}
+
+// SetUserRebateRate 设置或清除用户专属返利比例。ratePercent==nil 表示清除(沿用全局)。
+func (r *affiliateRepository) SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
+ if userID <= 0 {
+ return service.ErrUserNotFound
+ }
+ return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
+ return err
+ }
+ // nullableArg lets us use a single UPDATE for both "set value" and
+ // "clear" cases — database/sql converts nil interface{} to SQL NULL.
+ res, err := txClient.ExecContext(txCtx, `
+UPDATE user_affiliates
+SET aff_rebate_rate_percent = $1,
+ updated_at = NOW()
+WHERE user_id = $2`, nullableArg(ratePercent), userID)
+ if err != nil {
+ return fmt.Errorf("set aff_rebate_rate_percent: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return service.ErrUserNotFound
+ }
+ return nil
+ })
+}
+
+// BatchSetUserRebateRate 批量为多个用户设置专属比例(nil 清除)。
+func (r *affiliateRepository) BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
+ if len(userIDs) == 0 {
+ return nil
+ }
+ return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ for _, uid := range userIDs {
+ if uid <= 0 {
+ continue
+ }
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, uid); err != nil {
+ return err
+ }
+ }
+ _, err := txClient.ExecContext(txCtx, `
+UPDATE user_affiliates
+SET aff_rebate_rate_percent = $1,
+ updated_at = NOW()
+WHERE user_id = ANY($2)`, nullableArg(ratePercent), pq.Array(userIDs))
+ if err != nil {
+ return fmt.Errorf("batch set aff_rebate_rate_percent: %w", err)
+ }
+ return nil
+ })
+}
+
+// nullableArg unwraps a *float64 into an interface{} suitable for SQL parameter
+// binding: nil pointer → SQL NULL, non-nil → the float value.
+func nullableArg(v *float64) any {
+ if v == nil {
+ return nil
+ }
+ return *v
+}
+
+// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
+//
+// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
+// 空 search 时拼接出的 LIKE 模式为 "%%",匹配所有行;非空时按 ILIKE 子串匹配。
+// 这避免了为两种情况维护两份 SQL 模板。
+func (r *affiliateRepository) ListUsersWithCustomSettings(ctx context.Context, filter service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
+ page := filter.Page
+ if page < 1 {
+ page = 1
+ }
+ pageSize := filter.PageSize
+ if pageSize <= 0 || pageSize > 200 {
+ pageSize = 20
+ }
+ offset := (page - 1) * pageSize
+ likePattern := "%" + strings.TrimSpace(filter.Search) + "%"
+
+ const baseFrom = `
+FROM user_affiliates ua
+JOIN users u ON u.id = ua.user_id
+WHERE (ua.aff_code_custom = true OR ua.aff_rebate_rate_percent IS NOT NULL)
+ AND (u.email ILIKE $1 OR u.username ILIKE $1)`
+
+ client := clientFromContext(ctx, r.client)
+
+ total, err := scanInt64(ctx, client, "SELECT COUNT(*)"+baseFrom, likePattern)
+ if err != nil {
+ return nil, 0, fmt.Errorf("count affiliate admin entries: %w", err)
+ }
+
+ listQuery := `
+SELECT ua.user_id,
+ COALESCE(u.email, ''),
+ COALESCE(u.username, ''),
+ ua.aff_code,
+ ua.aff_code_custom,
+ ua.aff_rebate_rate_percent,
+ ua.aff_count` + baseFrom + `
+ORDER BY ua.updated_at DESC
+LIMIT $2 OFFSET $3`
+
+ rows, err := client.QueryContext(ctx, listQuery, likePattern, pageSize, offset)
+ if err != nil {
+ return nil, 0, fmt.Errorf("list affiliate admin entries: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ entries := make([]service.AffiliateAdminEntry, 0)
+ for rows.Next() {
+ var e service.AffiliateAdminEntry
+ var rebate sql.NullFloat64
+ if err := rows.Scan(&e.UserID, &e.Email, &e.Username, &e.AffCode,
+ &e.AffCodeCustom, &rebate, &e.AffCount); err != nil {
+ return nil, 0, err
+ }
+ if rebate.Valid {
+ v := rebate.Float64
+ e.AffRebateRatePercent = &v
+ }
+ entries = append(entries, e)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return entries, total, nil
+}
+
+// scanInt64 runs a query expected to return a single int64 column (e.g. COUNT).
+func scanInt64(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
+ rows, err := client.QueryContext(ctx, query, args...)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = rows.Close() }()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ return 0, nil
+ }
+ var v int64
+ if err := rows.Scan(&v); err != nil {
+ return 0, err
+ }
+ return v, nil
+}
diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go
index 3fa84426..369f57cf 100644
--- a/backend/internal/repository/affiliate_repo_integration_test.go
+++ b/backend/internal/repository/affiliate_repo_integration_test.go
@@ -182,3 +182,218 @@ VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
"SELECT balance::double precision FROM users WHERE id = $1", u.ID)
require.InDelta(t, 3.21, persistedBalance, 1e-9)
}
+
+// TestAffiliateRepository_AdminCustomCode covers the success path of admin
+// invite-code rewrite + reset within a shared test transaction:
+// - UpdateUserAffCode replaces aff_code, sets aff_code_custom=true, lookup works
+// - the old code can no longer be found
+// - ResetUserAffCode reverts aff_code_custom and assigns a new system-format code
+//
+// The conflict path (duplicate code → ErrAffiliateCodeTaken) lives in its own
+// test because a unique-violation aborts the surrounding Postgres tx, which
+// would poison subsequent assertions in the same transaction.
+func TestAffiliateRepository_AdminCustomCode(t *testing.T) {
+ ctx := context.Background()
+ tx := testEntTx(t)
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ repo := NewAffiliateRepository(client, integrationDB)
+
+ u := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-custom-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ })
+
+ original, err := repo.EnsureUserAffiliate(txCtx, u.ID)
+ require.NoError(t, err)
+ require.False(t, original.AffCodeCustom, "system-generated codes start as non-custom")
+ originalCode := original.AffCode
+
+ // Rewrite to a custom code
+ customCode := fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)
+ require.NoError(t, repo.UpdateUserAffCode(txCtx, u.ID, customCode))
+
+ updated, err := repo.EnsureUserAffiliate(txCtx, u.ID)
+ require.NoError(t, err)
+ require.Equal(t, customCode, updated.AffCode)
+ require.True(t, updated.AffCodeCustom)
+
+ // Lookup by new custom code finds the user
+ byCode, err := repo.GetAffiliateByCode(txCtx, customCode)
+ require.NoError(t, err)
+ require.Equal(t, u.ID, byCode.UserID)
+
+ // Old system code should no longer match
+ _, err = repo.GetAffiliateByCode(txCtx, originalCode)
+ require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
+
+ // Reset back to a fresh system code, clears custom flag
+ newSysCode, err := repo.ResetUserAffCode(txCtx, u.ID)
+ require.NoError(t, err)
+ require.NotEqual(t, customCode, newSysCode)
+
+ reset, err := repo.EnsureUserAffiliate(txCtx, u.ID)
+ require.NoError(t, err)
+ require.Equal(t, newSysCode, reset.AffCode)
+ require.False(t, reset.AffCodeCustom)
+
+ // The old custom code is now free again
+ _, err = repo.GetAffiliateByCode(txCtx, customCode)
+ require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
+}
+
+// TestAffiliateRepository_AdminCustomCode_Conflict isolates the unique-violation
+// path. PostgreSQL aborts the enclosing tx when a unique constraint fires, so
+// this test must be the only assertion and run in its own tx — production
+// callers each have their own outer tx, so this matches real behavior.
+func TestAffiliateRepository_AdminCustomCode_Conflict(t *testing.T) {
+ ctx := context.Background()
+ tx := testEntTx(t)
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ repo := NewAffiliateRepository(client, integrationDB)
+
+ taker := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-conflict-taker-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser, Status: service.StatusActive,
+ })
+ requester := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-conflict-req-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser, Status: service.StatusActive,
+ })
+
+ takenCode := fmt.Sprintf("HOT%09d", time.Now().UnixNano()%1_000_000_000)
+ require.NoError(t, repo.UpdateUserAffCode(txCtx, taker.ID, takenCode))
+
+ // Now requester tries to grab the same code → conflict.
+ err := repo.UpdateUserAffCode(txCtx, requester.ID, takenCode)
+ require.ErrorIs(t, err, service.ErrAffiliateCodeTaken)
+}
+
+// TestAffiliateRepository_AdminRebateRate covers per-user exclusive rate
+// set/clear and the Batch variant including NULL semantics.
+func TestAffiliateRepository_AdminRebateRate(t *testing.T) {
+ ctx := context.Background()
+ tx := testEntTx(t)
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ repo := NewAffiliateRepository(client, integrationDB)
+
+ u1 := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-rate-%d-a@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ })
+ u2 := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-rate-%d-b@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ })
+
+ // Set exclusive rate for u1
+ rate := 42.5
+ require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, &rate))
+
+ got, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, got.AffRebateRatePercent)
+ require.InDelta(t, 42.5, *got.AffRebateRatePercent, 1e-9)
+
+ // Clear exclusive rate
+ require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, nil))
+ cleared, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
+ require.NoError(t, err)
+ require.Nil(t, cleared.AffRebateRatePercent)
+
+ // Batch set both users
+ batchRate := 15.0
+ require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, &batchRate))
+
+ for _, uid := range []int64{u1.ID, u2.ID} {
+ v, err := repo.EnsureUserAffiliate(txCtx, uid)
+ require.NoError(t, err)
+ require.NotNil(t, v.AffRebateRatePercent)
+ require.InDelta(t, 15.0, *v.AffRebateRatePercent, 1e-9)
+ }
+
+ // Batch clear
+ require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, nil))
+ for _, uid := range []int64{u1.ID, u2.ID} {
+ v, err := repo.EnsureUserAffiliate(txCtx, uid)
+ require.NoError(t, err)
+ require.Nil(t, v.AffRebateRatePercent)
+ }
+}
+
+// TestAffiliateRepository_ListUsersWithCustomSettings verifies the admin list
+// only includes users with at least one override applied.
+func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
+ ctx := context.Background()
+ tx := testEntTx(t)
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ repo := NewAffiliateRepository(client, integrationDB)
+
+ // User without any custom config — should NOT appear in the list.
+ plainEmail := fmt.Sprintf("affiliate-plain-%d@example.com", time.Now().UnixNano())
+ uPlain := mustCreateUser(t, client, &service.User{
+ Email: plainEmail, PasswordHash: "hash",
+ Role: service.RoleUser, Status: service.StatusActive,
+ })
+ _, err := repo.EnsureUserAffiliate(txCtx, uPlain.ID)
+ require.NoError(t, err)
+
+ // User with a custom code — should appear.
+ uCode := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-codeonly-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser, Status: service.StatusActive,
+ })
+ require.NoError(t, repo.UpdateUserAffCode(txCtx, uCode.ID, fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)))
+
+ // User with only an exclusive rate — should appear.
+ uRate := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("affiliate-rateonly-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser, Status: service.StatusActive,
+ })
+ r := 33.3
+ require.NoError(t, repo.SetUserRebateRate(txCtx, uRate.ID, &r))
+
+ entries, total, err := repo.ListUsersWithCustomSettings(txCtx, service.AffiliateAdminFilter{
+ Page: 1, PageSize: 100,
+ })
+ require.NoError(t, err)
+
+ // Build a quick lookup to assert per-user attributes (other tests may have
+ // inserted custom rows in the same DB; we only care about our 3).
+ byUserID := make(map[int64]service.AffiliateAdminEntry, len(entries))
+ for _, e := range entries {
+ byUserID[e.UserID] = e
+ }
+
+ require.NotContains(t, byUserID, uPlain.ID, "users without overrides must not appear")
+
+ codeEntry, ok := byUserID[uCode.ID]
+ require.True(t, ok, "custom-code user missing from list")
+ require.True(t, codeEntry.AffCodeCustom)
+ require.Nil(t, codeEntry.AffRebateRatePercent)
+
+ rateEntry, ok := byUserID[uRate.ID]
+ require.True(t, ok, "custom-rate user missing from list")
+ require.False(t, rateEntry.AffCodeCustom)
+ require.NotNil(t, rateEntry.AffRebateRatePercent)
+ require.InDelta(t, 33.3, *rateEntry.AffRebateRatePercent, 1e-9)
+
+ require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
+}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 35a6524a..39286cbf 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -775,6 +775,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
+ "affiliate_enabled": false,
"wechat_connect_enabled": false,
"wechat_connect_app_id": "",
"wechat_connect_app_secret_configured": false,
@@ -951,6 +952,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
+ "affiliate_enabled": false,
"wechat_connect_enabled": true,
"wechat_connect_app_id": "wx-open-config",
"wechat_connect_app_secret_configured": true,
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index 70160f7e..1c786f50 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -91,6 +91,9 @@ func RegisterAdminRoutes(
// 渠道监控
registerChannelMonitorRoutes(admin, h)
+
+ // 邀请返利(专属用户管理)
+ registerAffiliateRoutes(admin, h)
}
}
@@ -594,3 +597,18 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
}
}
+
+// registerAffiliateRoutes 注册邀请返利的管理端路由(专属用户配置)
+func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
+ affiliates := admin.Group("/affiliates")
+ {
+ users := affiliates.Group("/users")
+ {
+ users.GET("", h.Admin.Affiliate.ListUsers)
+ users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
+ users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
+ users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
+ users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
+ }
+ }
+}
diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go
index fa8e2018..560b71ab 100644
--- a/backend/internal/service/affiliate_service.go
+++ b/backend/internal/service/affiliate_service.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"math"
- "strconv"
"strings"
"time"
@@ -15,28 +14,39 @@ import (
var (
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
+ ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
)
const (
affiliateInviteesLimit = 100
- // affiliateCodeFormatLength must stay in sync with repository.affiliateCodeLength.
- affiliateCodeFormatLength = 12
+ // AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
+ // 12-char codes and admin-customized codes (e.g. "VIP2026").
+ AffiliateCodeMinLength = 4
+ AffiliateCodeMaxLength = 32
)
-// affiliateCodeValidChar is a 256-entry lookup table mirroring the charset used
-// by the repository's generateAffiliateCode (A-Z minus I/O, digits 2-9).
+// affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
+// All input passes through strings.ToUpper before validation, so lowercase from
+// users is normalized — admins may supply mixed case in their UI.
var affiliateCodeValidChar = func() [256]bool {
var tbl [256]bool
- for _, c := range []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
+ for c := byte('A'); c <= 'Z'; c++ {
tbl[c] = true
}
+ for c := byte('0'); c <= '9'; c++ {
+ tbl[c] = true
+ }
+ tbl['_'] = true
+ tbl['-'] = true
return tbl
}()
+// isValidAffiliateCodeFormat validates code format for both binding (user input)
+// and admin updates. Caller is expected to upper-case the input first.
func isValidAffiliateCodeFormat(code string) bool {
- if len(code) != affiliateCodeFormatLength {
+ if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
return false
}
for i := 0; i < len(code); i++ {
@@ -48,14 +58,16 @@ func isValidAffiliateCodeFormat(code string) bool {
}
type AffiliateSummary struct {
- UserID int64 `json:"user_id"`
- AffCode string `json:"aff_code"`
- InviterID *int64 `json:"inviter_id,omitempty"`
- AffCount int `json:"aff_count"`
- AffQuota float64 `json:"aff_quota"`
- AffHistoryQuota float64 `json:"aff_history_quota"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ UserID int64 `json:"user_id"`
+ AffCode string `json:"aff_code"`
+ AffCodeCustom bool `json:"aff_code_custom"`
+ AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
+ InviterID *int64 `json:"inviter_id,omitempty"`
+ AffCount int `json:"aff_count"`
+ AffQuota float64 `json:"aff_quota"`
+ AffHistoryQuota float64 `json:"aff_history_quota"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
type AffiliateInvitee struct {
@@ -72,7 +84,11 @@ type AffiliateDetail struct {
AffCount int `json:"aff_count"`
AffQuota float64 `json:"aff_quota"`
AffHistoryQuota float64 `json:"aff_history_quota"`
- Invitees []AffiliateInvitee `json:"invitees"`
+ // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
+ // 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。
+ // 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
+ EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
+ Invitees []AffiliateInvitee `json:"invitees"`
}
type AffiliateRepository interface {
@@ -82,24 +98,57 @@ type AffiliateRepository interface {
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
+
+ // 管理端:用户级专属配置
+ UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error
+ ResetUserAffCode(ctx context.Context, userID int64) (string, error)
+ SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
+ BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
+ ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
+}
+
+// AffiliateAdminFilter 列表筛选条件
+type AffiliateAdminFilter struct {
+ Search string
+ Page int
+ PageSize int
+}
+
+// AffiliateAdminEntry 专属用户列表条目
+type AffiliateAdminEntry struct {
+ UserID int64 `json:"user_id"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+ AffCode string `json:"aff_code"`
+ AffCodeCustom bool `json:"aff_code_custom"`
+ AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
+ AffCount int `json:"aff_count"`
}
type AffiliateService struct {
repo AffiliateRepository
- settingRepo SettingRepository
+ settingService *SettingService
authCacheInvalidator APIKeyAuthCacheInvalidator
billingCacheService *BillingCacheService
}
-func NewAffiliateService(repo AffiliateRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
+func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
return &AffiliateService{
repo: repo,
- settingRepo: settingRepo,
+ settingService: settingService,
authCacheInvalidator: authCacheInvalidator,
billingCacheService: billingCacheService,
}
}
+// IsEnabled reports whether the affiliate (邀请返利) feature is turned on.
+func (s *AffiliateService) IsEnabled(ctx context.Context) bool {
+ if s == nil || s.settingService == nil {
+ return AffiliateEnabledDefault
+ }
+ return s.settingService.IsAffiliateEnabled(ctx)
+}
+
func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
@@ -120,13 +169,14 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
return nil, err
}
return &AffiliateDetail{
- UserID: summary.UserID,
- AffCode: summary.AffCode,
- InviterID: summary.InviterID,
- AffCount: summary.AffCount,
- AffQuota: summary.AffQuota,
- AffHistoryQuota: summary.AffHistoryQuota,
- Invitees: invitees,
+ UserID: summary.UserID,
+ AffCode: summary.AffCode,
+ InviterID: summary.InviterID,
+ AffCount: summary.AffCount,
+ AffQuota: summary.AffQuota,
+ AffHistoryQuota: summary.AffHistoryQuota,
+ EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
+ Invitees: invitees,
}, nil
}
@@ -135,12 +185,16 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
if code == "" {
return nil
}
- if !isValidAffiliateCodeFormat(code) {
- return ErrAffiliateCodeInvalid
- }
if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
+ // 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程)
+ if !s.IsEnabled(ctx) {
+ return nil
+ }
+ if !isValidAffiliateCodeFormat(code) {
+ return ErrAffiliateCodeInvalid
+ }
selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
if err != nil {
@@ -178,6 +232,10 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
return 0, nil
}
+ // 总开关关闭时,新充值不再产生返利
+ if !s.IsEnabled(ctx) {
+ return 0, nil
+ }
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
if err != nil {
@@ -187,16 +245,17 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
return 0, nil
}
- rebateRatePercent := s.loadAffiliateRebateRatePercent(ctx)
+ // 加载邀请人 profile,优先使用专属比例(覆盖全局)
+ inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
+ if err != nil {
+ return 0, err
+ }
+ rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
if rebate <= 0 {
return 0, nil
}
- if _, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID); err != nil {
- return 0, err
- }
-
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate)
if err != nil {
return 0, err
@@ -207,6 +266,28 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
return rebate, nil
}
+// resolveRebateRatePercent returns the inviter's exclusive rate when set,
+// otherwise the global setting value (clamped to [Min, Max]).
+func (s *AffiliateService) resolveRebateRatePercent(ctx context.Context, inviter *AffiliateSummary) float64 {
+ if inviter != nil && inviter.AffRebateRatePercent != nil {
+ v := *inviter.AffRebateRatePercent
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return s.globalRebateRatePercent(ctx)
+ }
+ return clampAffiliateRebateRate(v)
+ }
+ return s.globalRebateRatePercent(ctx)
+}
+
+// globalRebateRatePercent reads the system-wide rebate rate via SettingService,
+// returning the documented default when SettingService is unavailable.
+func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64 {
+ if s == nil || s.settingService == nil {
+ return AffiliateRebateRateDefault
+ }
+ return s.settingService.GetAffiliateRebateRatePercent(ctx)
+}
+
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
if s == nil || s.repo == nil {
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
@@ -236,32 +317,6 @@ func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([
return invitees, nil
}
-func (s *AffiliateService) loadAffiliateRebateRatePercent(ctx context.Context) float64 {
- if s == nil || s.settingRepo == nil {
- return AffiliateRebateRateDefault
- }
-
- raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
- if err != nil {
- return AffiliateRebateRateDefault
- }
-
- rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
- if err != nil {
- return AffiliateRebateRateDefault
- }
- if math.IsNaN(rate) || math.IsInf(rate, 0) {
- return AffiliateRebateRateDefault
- }
- if rate < AffiliateRebateRateMin {
- return AffiliateRebateRateMin
- }
- if rate > AffiliateRebateRateMax {
- return AffiliateRebateRateMax
- }
- return rate
-}
-
func roundTo(v float64, scale int) float64 {
factor := math.Pow10(scale)
return math.Round(v*factor) / factor
@@ -312,3 +367,82 @@ func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID
}
}
}
+
+// =========================
+// Admin: 专属配置管理
+// =========================
+
+// validateExclusiveRate ensures a per-user override is finite and within
+// [Min, Max]. nil is always valid (means "clear / fall back to global").
+func validateExclusiveRate(ratePercent *float64) error {
+ if ratePercent == nil {
+ return nil
+ }
+ v := *ratePercent
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return infraerrors.BadRequest("INVALID_RATE", "invalid rebate rate")
+ }
+ if v < AffiliateRebateRateMin || v > AffiliateRebateRateMax {
+ return infraerrors.BadRequest("INVALID_RATE", "rebate rate out of range")
+ }
+ return nil
+}
+
+// AdminUpdateUserAffCode 管理员改写用户的邀请码(专属邀请码)。
+func (s *AffiliateService) AdminUpdateUserAffCode(ctx context.Context, userID int64, rawCode string) error {
+ if s == nil || s.repo == nil {
+ return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ code := strings.ToUpper(strings.TrimSpace(rawCode))
+ if !isValidAffiliateCodeFormat(code) {
+ return ErrAffiliateCodeInvalid
+ }
+ return s.repo.UpdateUserAffCode(ctx, userID, code)
+}
+
+// AdminResetUserAffCode 重置用户邀请码为系统随机码。
+func (s *AffiliateService) AdminResetUserAffCode(ctx context.Context, userID int64) (string, error) {
+ if s == nil || s.repo == nil {
+ return "", infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.ResetUserAffCode(ctx, userID)
+}
+
+// AdminSetUserRebateRate 设置/清除用户专属返利比例。ratePercent==nil 表示清除。
+func (s *AffiliateService) AdminSetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
+ if s == nil || s.repo == nil {
+ return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ if err := validateExclusiveRate(ratePercent); err != nil {
+ return err
+ }
+ return s.repo.SetUserRebateRate(ctx, userID, ratePercent)
+}
+
+// AdminBatchSetUserRebateRate 批量设置/清除用户专属返利比例。
+func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
+ if s == nil || s.repo == nil {
+ return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ if err := validateExclusiveRate(ratePercent); err != nil {
+ return err
+ }
+ cleaned := make([]int64, 0, len(userIDs))
+ for _, uid := range userIDs {
+ if uid > 0 {
+ cleaned = append(cleaned, uid)
+ }
+ }
+ if len(cleaned) == 0 {
+ return nil
+ }
+ return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
+}
+
+// AdminListCustomUsers 列出有专属配置的用户。
+func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
+ if s == nil || s.repo == nil {
+ return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.ListUsersWithCustomSettings(ctx, filter)
+}
diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go
index 605fe00f..c02a4dd7 100644
--- a/backend/internal/service/affiliate_service_test.go
+++ b/backend/internal/service/affiliate_service_test.go
@@ -4,51 +4,82 @@ package service
import (
"context"
+ "math"
"testing"
"github.com/stretchr/testify/require"
)
-type affiliateSettingRepoStub struct {
- value string
- err error
-}
-
-func (s *affiliateSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, s.err }
-func (s *affiliateSettingRepoStub) GetValue(context.Context, string) (string, error) {
- if s.err != nil {
- return "", s.err
- }
- return s.value, nil
-}
-func (s *affiliateSettingRepoStub) Set(context.Context, string, string) error { return s.err }
-func (s *affiliateSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
- if s.err != nil {
- return nil, s.err
- }
- return map[string]string{}, nil
-}
-func (s *affiliateSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
- return s.err
-}
-func (s *affiliateSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
- if s.err != nil {
- return nil, s.err
- }
- return map[string]string{}, nil
-}
-func (s *affiliateSettingRepoStub) Delete(context.Context, string) error { return s.err }
-
-func TestAffiliateRebateRatePercentSemantics(t *testing.T) {
+// TestResolveRebateRatePercent_PerUserOverride verifies that per-inviter
+// AffRebateRatePercent overrides the global rate, that NULL falls back to the
+// global rate, and that out-of-range exclusive rates are clamped silently.
+//
+// SettingService is left nil here so globalRebateRatePercent returns the
+// documented default (AffiliateRebateRateDefault = 20%) — this exercises the
+// fallback path without spinning up a settings stub.
+func TestResolveRebateRatePercent_PerUserOverride(t *testing.T) {
t.Parallel()
+ svc := &AffiliateService{}
- svc := &AffiliateService{settingRepo: &affiliateSettingRepoStub{value: "1"}}
- rate := svc.loadAffiliateRebateRatePercent(context.Background())
- require.Equal(t, 1.0, rate)
+ // nil exclusive rate → falls back to global default (20%)
+ require.InDelta(t, AffiliateRebateRateDefault,
+ svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{}), 1e-9)
- svc.settingRepo = &affiliateSettingRepoStub{value: "0.2"}
- rate = svc.loadAffiliateRebateRatePercent(context.Background())
- require.Equal(t, 0.2, rate)
+ // exclusive rate set → overrides global
+ rate := 50.0
+ require.InDelta(t, 50.0,
+ svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &rate}), 1e-9)
+
+ // exclusive rate 0 → returns 0 (no rebate, intentional)
+ zero := 0.0
+ require.InDelta(t, 0.0,
+ svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &zero}), 1e-9)
+
+ // exclusive rate above max → clamped to Max
+ tooHigh := 250.0
+ require.InDelta(t, AffiliateRebateRateMax,
+ svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooHigh}), 1e-9)
+
+ // exclusive rate below min → clamped to Min
+ tooLow := -5.0
+ require.InDelta(t, AffiliateRebateRateMin,
+ svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooLow}), 1e-9)
+}
+
+// TestIsEnabled_NilSettingServiceReturnsDefault verifies that IsEnabled
+// safely handles a nil settingService dependency by returning the default
+// (off). This protects callers from nil-pointer crashes in misconfigured
+// environments.
+func TestIsEnabled_NilSettingServiceReturnsDefault(t *testing.T) {
+ t.Parallel()
+ svc := &AffiliateService{}
+ require.False(t, svc.IsEnabled(context.Background()))
+ require.Equal(t, AffiliateEnabledDefault, svc.IsEnabled(context.Background()))
+}
+
+// TestValidateExclusiveRate_BoundaryAndInvalid covers the validator used by
+// admin-facing rate setters: nil is always valid (clear), in-range values
+// are accepted, NaN/Inf and out-of-range values produce a typed BadRequest.
+func TestValidateExclusiveRate_BoundaryAndInvalid(t *testing.T) {
+ t.Parallel()
+ require.NoError(t, validateExclusiveRate(nil))
+
+ for _, v := range []float64{0, 0.01, 50, 99.99, 100} {
+ v := v
+ require.NoError(t, validateExclusiveRate(&v), "value %v should be valid", v)
+ }
+
+ for _, v := range []float64{-0.01, 100.01, -100, 200} {
+ v := v
+ require.Error(t, validateExclusiveRate(&v), "value %v should be rejected", v)
+ }
+
+ nan := math.NaN()
+ require.Error(t, validateExclusiveRate(&nan))
+ posInf := math.Inf(1)
+ require.Error(t, validateExclusiveRate(&posInf))
+ negInf := math.Inf(-1)
+ require.Error(t, validateExclusiveRate(&negInf))
}
func TestMaskEmail(t *testing.T) {
@@ -61,24 +92,33 @@ func TestMaskEmail(t *testing.T) {
func TestIsValidAffiliateCodeFormat(t *testing.T) {
t.Parallel()
+ // 邀请码格式校验同时服务于:
+ // 1) 系统自动生成的 12 位随机码(A-Z 去 I/O,2-9 去 0/1)
+ // 2) 管理员设置的自定义专属码(如 "VIP2026"、"NEW_USER-1")
+ // 因此校验放宽到 [A-Z0-9_-]{4,32}(要求调用方先 ToUpper)。
cases := []struct {
name string
in string
want bool
}{
- {"valid canonical", "ABCDEFGHJKLM", true},
+ {"valid canonical 12-char", "ABCDEFGHJKLM", true},
{"valid all digits 2-9", "234567892345", true},
{"valid mixed", "A2B3C4D5E6F7", true},
- {"too short", "ABCDEFGHJKL", false},
- {"too long", "ABCDEFGHJKLMN", false},
- {"contains excluded letter I", "IBCDEFGHJKLM", false},
- {"contains excluded letter O", "OBCDEFGHJKLM", false},
- {"contains excluded digit 0", "0BCDEFGHJKLM", false},
- {"contains excluded digit 1", "1BCDEFGHJKLM", false},
+ {"valid admin custom short", "VIP1", true},
+ {"valid admin custom with hyphen", "NEW-USER", true},
+ {"valid admin custom with underscore", "VIP_2026", true},
+ {"valid 32-char max", "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", true},
+ // Previously-excluded chars (I/O/0/1) are now allowed since admins may use them.
+ {"letter I now allowed", "IBCDEFGHJKLM", true},
+ {"letter O now allowed", "OBCDEFGHJKLM", true},
+ {"digit 0 now allowed", "0BCDEFGHJKLM", true},
+ {"digit 1 now allowed", "1BCDEFGHJKLM", true},
+ {"too short (3 chars)", "ABC", false},
+ {"too long (33 chars)", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", false},
{"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false},
{"empty", "", false},
- {"12-byte utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // 6×2 bytes = 12 bytes, bytes out of charset
- {"ascii punctuation", "ABCDEFGHJK.M", false},
+ {"utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // bytes out of charset
+ {"ascii punctuation .", "ABCDEFGHJK.M", false},
{"whitespace", "ABCDEFGHJK M", false},
}
for _, tc := range cases {
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 23afeb87..04037987 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -23,6 +23,7 @@ const (
AffiliateRebateRateDefault = 20.0
AffiliateRebateRateMin = 0.0
AffiliateRebateRateMax = 100.0
+ AffiliateEnabledDefault = false // 邀请返利总开关默认关闭
)
// Platform constants
@@ -94,6 +95,7 @@ const (
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
+ SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
// 邮件服务设置
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index f3801c48..f871ee85 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -454,6 +454,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled,
+ SettingKeyAffiliateEnabled,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -541,6 +542,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
+
+ AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
}, nil
}
@@ -687,6 +690,7 @@ type PublicSettingsInjectionPayload struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+ AffiliateEnabled bool `json:"affiliate_enabled"`
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@@ -739,6 +743,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
+ AffiliateEnabled: settings.AffiliateEnabled,
}, nil
}
@@ -1205,6 +1210,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Available channels feature switch
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
+ // Affiliate (邀请返利) feature switch
+ updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
+
// Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
@@ -1480,6 +1488,30 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
return value == "true"
}
+// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
+func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
+ value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
+ if err != nil {
+ return false // 默认关闭
+ }
+ return value == "true"
+}
+
+// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
+// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
+// 调用方只关心一个可用的数值。
+func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
+ raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
+ if err != nil {
+ return AffiliateRebateRateDefault
+ }
+ rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+ if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
+ return AffiliateRebateRateDefault
+ }
+ return clampAffiliateRebateRate(rate)
+}
+
// IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
@@ -1771,6 +1803,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false",
+ // Affiliate (邀请返利) feature (default disabled; opt-in)
+ SettingKeyAffiliateEnabled: "false",
+
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
@@ -2091,6 +2126,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
+ // Affiliate (邀请返利) feature (default: disabled; strict true)
+ result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
+
// Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 8a3bd421..70d8efc3 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency int
DefaultBalance float64
+ AffiliateEnabled bool
AffiliateRebateRate float64
DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting
@@ -225,6 +226,9 @@ type PublicSettings struct {
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+
+ // Affiliate (邀请返利) feature toggle
+ AffiliateEnabled bool `json:"affiliate_enabled"`
}
type WeChatConnectOAuthConfig struct {
diff --git a/backend/migrations/132_affiliate_custom_settings.sql b/backend/migrations/132_affiliate_custom_settings.sql
new file mode 100644
index 00000000..840fe8e0
--- /dev/null
+++ b/backend/migrations/132_affiliate_custom_settings.sql
@@ -0,0 +1,16 @@
+-- 邀请返利:用户专属配置增强
+-- 1) aff_rebate_rate_percent: 用户作为邀请人时的专属返利比例(百分比,NULL 表示沿用全局比例)
+-- 2) aff_code_custom: 标记当前 aff_code 是否被管理员手动改写过(用于"专属用户"列表筛选)
+
+ALTER TABLE user_affiliates
+ ADD COLUMN IF NOT EXISTS aff_rebate_rate_percent DECIMAL(5,2);
+
+ALTER TABLE user_affiliates
+ ADD COLUMN IF NOT EXISTS aff_code_custom BOOLEAN NOT NULL DEFAULT false;
+
+CREATE INDEX IF NOT EXISTS idx_user_affiliates_admin_settings
+ ON user_affiliates (updated_at)
+ WHERE aff_code_custom = true OR aff_rebate_rate_percent IS NOT NULL;
+
+COMMENT ON COLUMN user_affiliates.aff_rebate_rate_percent IS '专属返利比例(百分比 0-100,NULL 表示沿用全局)';
+COMMENT ON COLUMN user_affiliates.aff_code_custom IS '邀请码是否由管理员改写过(用于专属用户筛选)';
diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts
new file mode 100644
index 00000000..22639bd2
--- /dev/null
+++ b/frontend/src/api/admin/affiliates.ts
@@ -0,0 +1,108 @@
+/**
+ * Admin Affiliate API endpoints
+ * Manage per-user affiliate (邀请返利) configurations:
+ * exclusive invite codes (overrides aff_code) and exclusive rebate rates.
+ */
+
+import { apiClient } from '../client'
+import type { PaginatedResponse } from '@/types'
+
+export interface AffiliateAdminEntry {
+ user_id: number
+ email: string
+ username: string
+ aff_code: string
+ aff_code_custom: boolean
+ aff_rebate_rate_percent?: number | null
+ aff_count: number
+}
+
+export interface ListAffiliateUsersParams {
+ page?: number
+ page_size?: number
+ search?: string
+}
+
+export interface UpdateAffiliateUserRequest {
+ aff_code?: string
+ aff_rebate_rate_percent?: number | null
+ /** Set true to explicitly clear the per-user rate (sets it to NULL). */
+ clear_rebate_rate?: boolean
+}
+
+export interface BatchSetRateRequest {
+ user_ids: number[]
+ aff_rebate_rate_percent?: number | null
+ /** Set true to clear rates instead of setting. */
+ clear?: boolean
+}
+
+export interface SimpleUser {
+ id: number
+ email: string
+ username: string
+}
+
+export async function listUsers(
+ params: ListAffiliateUsersParams = {},
+): Promise
- {{ t("admin.settings.defaults.affiliateRebateRateHint") }} -
-+ {{ t('admin.settings.features.affiliate.description') }} +
++ {{ t('admin.settings.features.affiliate.enabledHint') }} +
++ {{ t('admin.settings.features.affiliate.rebateRateHint') }} +
++ {{ t('admin.settings.features.affiliate.customUsers.description') }} +
+| + + | +{{ t('admin.settings.features.affiliate.customUsers.col.email') }} | +{{ t('admin.settings.features.affiliate.customUsers.col.username') }} | +{{ t('admin.settings.features.affiliate.customUsers.col.code') }} | +{{ t('admin.settings.features.affiliate.customUsers.col.rate') }} | +{{ t('admin.settings.features.affiliate.customUsers.col.actions') }} | +
|---|---|---|---|---|---|
| + {{ t('common.loading') }} + | +|||||
| + {{ t('admin.settings.features.affiliate.customUsers.empty') }} + | +|||||
| + + | +{{ entry.email }} | +{{ entry.username }} | ++ {{ entry.aff_code }} + {{ t('admin.settings.features.affiliate.customUsers.customBadge') }} + | ++ {{ entry.aff_rebate_rate_percent }}% + {{ t('admin.settings.features.affiliate.customUsers.useGlobal') }} + | +
+
+
+
+
+ |
+
+ {{ t('admin.settings.features.affiliate.modal.codeHint') }} +
++ {{ t('admin.settings.features.affiliate.modal.rateHint') }} +
++ {{ t('admin.settings.features.affiliate.modal.errorEmpty') }} +
+ ++ {{ t('admin.settings.features.affiliate.batchModal.hint') }} +
++ {{ t('admin.settings.features.affiliate.batchModal.clearHint') }} +
+