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.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.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" /> +