From 4e1bb2b4453f03e2086f77767634337e61b7c4f6 Mon Sep 17 00:00:00 2001
From: shaw
Date: Sat, 25 Apr 2026 19:14:34 +0800
Subject: [PATCH] feat(affiliate): add feature toggle and per-user custom
invite settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
---
backend/cmd/server/wire_gen.go | 5 +-
.../handler/admin/affiliate_handler.go | 183 +++++
.../internal/handler/admin/setting_handler.go | 16 +
backend/internal/handler/dto/settings.go | 5 +
backend/internal/handler/handler.go | 1 +
backend/internal/handler/setting_handler.go | 2 +
backend/internal/handler/wire.go | 3 +
backend/internal/repository/affiliate_repo.go | 244 ++++++
.../affiliate_repo_integration_test.go | 215 +++++
backend/internal/server/api_contract_test.go | 2 +
backend/internal/server/routes/admin.go | 18 +
backend/internal/service/affiliate_service.go | 254 ++++--
.../service/affiliate_service_test.go | 132 ++--
backend/internal/service/domain_constants.go | 2 +
backend/internal/service/setting_service.go | 38 +
backend/internal/service/settings_view.go | 4 +
.../132_affiliate_custom_settings.sql | 16 +
frontend/src/api/admin/affiliates.ts | 108 +++
frontend/src/api/admin/index.ts | 7 +-
frontend/src/api/admin/settings.ts | 6 +
frontend/src/components/layout/AppSidebar.vue | 3 +-
frontend/src/i18n/locales/en.ts | 53 +-
frontend/src/i18n/locales/zh.ts | 53 +-
frontend/src/stores/app.ts | 1 +
frontend/src/types/index.ts | 3 +
frontend/src/utils/featureFlags.ts | 5 +
frontend/src/views/admin/SettingsView.vue | 744 +++++++++++++++++-
frontend/src/views/user/AffiliateView.vue | 28 +-
28 files changed, 2010 insertions(+), 141 deletions(-)
create mode 100644 backend/internal/handler/admin/affiliate_handler.go
create mode 100644 backend/migrations/132_affiliate_custom_settings.sql
create mode 100644 frontend/src/api/admin/affiliates.ts
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> {
+ const { data } = await apiClient.get>(
+ '/admin/affiliates/users',
+ {
+ params: {
+ page: params.page ?? 1,
+ page_size: params.page_size ?? 20,
+ search: params.search ?? '',
+ },
+ },
+ )
+ return data
+}
+
+export async function lookupUsers(q: string): Promise {
+ const { data } = await apiClient.get(
+ '/admin/affiliates/users/lookup',
+ { params: { q } },
+ )
+ return data
+}
+
+export async function updateUserSettings(
+ userId: number,
+ payload: UpdateAffiliateUserRequest,
+): Promise<{ user_id: number }> {
+ const { data } = await apiClient.put<{ user_id: number }>(
+ `/admin/affiliates/users/${userId}`,
+ payload,
+ )
+ return data
+}
+
+export async function clearUserSettings(
+ userId: number,
+): Promise<{ user_id: number }> {
+ const { data } = await apiClient.delete<{ user_id: number }>(
+ `/admin/affiliates/users/${userId}`,
+ )
+ return data
+}
+
+export async function batchSetRate(
+ payload: BatchSetRateRequest,
+): Promise<{ affected: number }> {
+ const { data } = await apiClient.post<{ affected: number }>(
+ '/admin/affiliates/users/batch-rate',
+ payload,
+ )
+ return data
+}
+
+export const affiliatesAPI = {
+ listUsers,
+ lookupUsers,
+ updateUserSettings,
+ clearUserSettings,
+ batchSetRate,
+}
+
+export default affiliatesAPI
diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts
index 9cda5814..80241794 100644
--- a/frontend/src/api/admin/index.ts
+++ b/frontend/src/api/admin/index.ts
@@ -29,6 +29,7 @@ import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
+import affiliatesAPI from './affiliates'
/**
* Unified admin API object for convenient access
@@ -59,7 +60,8 @@ export const adminAPI = {
channels: channelsAPI,
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
- payment: adminPaymentAPI
+ payment: adminPaymentAPI,
+ affiliates: affiliatesAPI
}
export {
@@ -88,7 +90,8 @@ export {
channelsAPI,
channelMonitorAPI,
channelMonitorTemplateAPI,
- adminPaymentAPI
+ adminPaymentAPI,
+ affiliatesAPI
}
export default adminAPI
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 971c2314..0d98c9e9 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -478,6 +478,9 @@ export interface SystemSettings {
// Available Channels feature switch
available_channels_enabled: boolean;
+
+ // Affiliate (邀请返利) feature switch
+ affiliate_enabled: boolean;
}
export interface UpdateSettingsRequest {
@@ -636,6 +639,9 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch
available_channels_enabled?: boolean;
+
+ // Affiliate (邀请返利) feature switch
+ affiliate_enabled?: boolean;
}
/**
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index a3a8c30e..d8e2794e 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -634,6 +634,7 @@ const ChevronDownIcon = {
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
+const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
@@ -656,7 +657,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
- { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true },
+ { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 5aa63e6a..42d68b70 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -985,6 +985,8 @@ export default {
loadFailed: 'Failed to load affiliate data',
transferFailed: 'Failed to transfer affiliate quota',
stats: {
+ rebateRate: 'My Rebate Rate',
+ rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota',
totalQuota: 'Historical Rebate Quota'
@@ -1009,7 +1011,7 @@ export default {
tips: {
title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.',
- line2: 'When invitees recharge, you receive rebate quota based on the configured rate.',
+ line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.'
}
},
@@ -4779,6 +4781,55 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
+ affiliate: {
+ title: 'Affiliate (Invite Rebate)',
+ description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
+ enabled: 'Enable Affiliate',
+ enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
+ rebateRate: 'Global Rebate Rate',
+ rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
+ customUsers: {
+ title: 'Per-User Overrides',
+ description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
+ addButton: 'Add Custom User',
+ searchPlaceholder: 'Search by email or username',
+ batchButton: 'Batch Set Rate ({count} selected)',
+ empty: 'No users with custom affiliate settings yet',
+ customBadge: 'custom',
+ useGlobal: 'use global',
+ resetTitle: 'Reset Custom Settings',
+ resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
+ totalLabel: '{total} total',
+ col: {
+ email: 'Email',
+ username: 'Username',
+ code: 'Invite Code',
+ rate: 'Custom Rate',
+ actions: 'Actions',
+ },
+ },
+ modal: {
+ addTitle: 'Add Custom User',
+ editTitle: 'Edit Custom Settings',
+ userLabel: 'User',
+ userPlaceholder: 'Search by email or username',
+ changeUser: 'Change user',
+ codeLabel: 'Custom Invite Code (optional)',
+ codePlaceholder: 'e.g. VIP2026',
+ codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
+ rateLabel: 'Exclusive Rebate Rate (optional)',
+ ratePlaceholder: 'e.g. 30',
+ rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
+ errorBadRate: 'Please enter a number between 0 and 100',
+ errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
+ },
+ batchModal: {
+ title: 'Batch Set Rate ({count} users selected)',
+ hint: 'Apply the same exclusive rebate rate to all selected users.',
+ placeholder: 'e.g. 30',
+ clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
+ },
+ },
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index b61248ff..7601d01c 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -989,6 +989,8 @@ export default {
loadFailed: '加载邀请返利数据失败',
transferFailed: '转入余额失败',
stats: {
+ rebateRate: '我的返利比例',
+ rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数',
availableQuota: '可转返利额度',
totalQuota: '历史返利额度'
@@ -1013,7 +1015,7 @@ export default {
tips: {
title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。',
- line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
+ line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。'
}
},
@@ -4942,6 +4944,55 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
+ affiliate: {
+ title: '邀请返利',
+ description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
+ enabled: '启用邀请返利',
+ enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
+ rebateRate: '全局返利比例',
+ rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
+ customUsers: {
+ title: '专属用户配置',
+ description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
+ addButton: '添加专属用户',
+ searchPlaceholder: '搜索邮箱或用户名',
+ batchButton: '批量设置比例(已选 {count})',
+ empty: '暂无专属配置用户',
+ customBadge: '自定义',
+ useGlobal: '沿用全局',
+ resetTitle: '重置该用户的专属配置',
+ resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
+ totalLabel: '共 {total} 条',
+ col: {
+ email: '邮箱',
+ username: '用户名',
+ code: '邀请码',
+ rate: '专属比例',
+ actions: '操作',
+ },
+ },
+ modal: {
+ addTitle: '添加专属用户',
+ editTitle: '编辑专属配置',
+ userLabel: '用户',
+ userPlaceholder: '搜索邮箱或用户名',
+ changeUser: '更换用户',
+ codeLabel: '专属邀请码(可选)',
+ codePlaceholder: '例如 VIP2026',
+ codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
+ rateLabel: '专属返利比例(可选)',
+ ratePlaceholder: '例如 30',
+ rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
+ errorBadRate: '请输入 0-100 之间的比例',
+ errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
+ },
+ batchModal: {
+ title: '批量设置专属比例(已选 {count} 个用户)',
+ hint: '为所选用户统一设置专属返利比例。',
+ placeholder: '例如 30',
+ clearHint: '留空提交将清除所选用户的专属比例。',
+ },
+ },
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index e6cd9eff..876ab5c0 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
+ affiliate_enabled: false,
}
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 50b4353e..2a15ad00 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
aff_count: number
aff_quota: number
aff_history_quota: number
+ /** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
+ effective_rebate_rate_percent: number
invitees: AffiliateInvitee[]
}
@@ -212,6 +214,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
+ affiliate_enabled: boolean
}
export interface AuthResponse {
diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts
index 51b043cc..e0668694 100644
--- a/frontend/src/utils/featureFlags.ts
+++ b/frontend/src/utils/featureFlags.ts
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out',
label: 'Payment',
}),
+ affiliate: defineFlag({
+ key: 'affiliate_enabled',
+ mode: 'opt-in',
+ label: 'Affiliate',
+ }),
} as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 6da4b21a..87113e59 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -2153,31 +2153,6 @@
{{ t("admin.settings.defaults.defaultBalanceHint") }}
-
-
-
-
- %
-
-
- {{ t("admin.settings.defaults.affiliateRebateRateHint") }}
-
-
+
+
+
+
+ {{ t('admin.settings.features.affiliate.title') }}
+
+
+ {{ t('admin.settings.features.affiliate.description') }}
+
+
+
+
+
+
+
+ {{ t('admin.settings.features.affiliate.enabledHint') }}
+
+
+
+
+
+
+
+
+
+
+ %
+
+
+ {{ t('admin.settings.features.affiliate.rebateRateHint') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.features.affiliate.customUsers.title') }}
+
+
+ {{ t('admin.settings.features.affiliate.customUsers.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.features.affiliate.customUsers.totalLabel', { total: affiliateState.total }) }}
+
+
+
+ {{ affiliateState.page }} / {{ Math.max(1, Math.ceil(affiliateState.total / affiliateState.pageSize)) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ affiliateModal.mode === 'add' ? t('admin.settings.features.affiliate.modal.addTitle') : t('admin.settings.features.affiliate.modal.editTitle') }}
+
+
+
+
+
+
+
+ {{ affiliateModal.selectedUser.email }}
+ ({{ affiliateModal.selectedUser.username }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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.title', { count: affiliateState.selected.length }) }}
+
+
+ {{ t('admin.settings.features.affiliate.batchModal.hint') }}
+
+
+
+ %
+
+
+ {{ t('admin.settings.features.affiliate.batchModal.clearHint') }}
+
+
+
+
+
+
+
+
@@ -4793,12 +5118,21 @@
@confirm="handleDeleteProvider"
@cancel="showDeleteProviderDialog = false"
/>
+