diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 93270e7e..f8e0dcf4 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -69,7 +69,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
- authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService)
+ affiliateRepository := repository.NewAffiliateRepository(client, db)
+ affiliateService := service.NewAffiliateService(affiliateRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCacheService)
+ authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
@@ -80,7 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpCache := repository.NewTotpCache(redisClient)
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
- userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache)
+ userHandler := handler.ProvideUserHandler(userService, authService, emailService, emailCache, affiliateService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
@@ -91,6 +93,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
+ channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
+ channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
+ channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
@@ -192,7 +197,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
registry := payment.ProvideRegistry()
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
- paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
+ paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
@@ -221,20 +226,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
- sqlDB, err := repository.ProvideSQLDB(client)
- if err != nil {
- return nil, err
- }
- channelMonitorRepository := repository.NewChannelMonitorRepository(client, sqlDB)
- channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, sqlDB)
+ channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
+ channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
- channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
- channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
- channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
- channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
- availableChannelUserHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
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)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
@@ -245,9 +241,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
+ availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
- handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelUserHandler, idempotencyCoordinator, idempotencyCleanupService)
+ handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
@@ -263,6 +260,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
+ channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
application := &Application{
Server: httpServer,
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 4277f0f1..2d4dcb5b 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -185,6 +185,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
+ AffiliateRebateRate: settings.AffiliateRebateRate,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback,
@@ -338,6 +339,7 @@ type UpdateSettingsRequest struct {
// 默认配置
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
+ AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
@@ -468,6 +470,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.DefaultBalance < 0 {
req.DefaultBalance = 0
}
+ affiliateRebateRate := previousSettings.AffiliateRebateRate
+ if req.AffiliateRebateRate != nil {
+ affiliateRebateRate = *req.AffiliateRebateRate
+ }
+ if affiliateRebateRate < service.AffiliateRebateRateMin {
+ affiliateRebateRate = service.AffiliateRebateRateMin
+ }
+ if affiliateRebateRate > service.AffiliateRebateRateMax {
+ affiliateRebateRate = service.AffiliateRebateRateMax
+ }
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if req.TableDefaultPageSize <= 0 {
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
@@ -1119,6 +1131,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
+ AffiliateRebateRate: affiliateRebateRate,
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback,
@@ -1433,6 +1446,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
+ AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback,
@@ -1738,6 +1752,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.DefaultBalance != after.DefaultBalance {
changed = append(changed, "default_balance")
}
+ if before.AffiliateRebateRate != after.AffiliateRebateRate {
+ changed = append(changed, "affiliate_rebate_rate")
+ }
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
changed = append(changed, "default_subscriptions")
}
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index dc68a466..1f9a66ff 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -48,6 +48,7 @@ type RegisterRequest struct {
TurnstileToken string `json:"turnstile_token"`
PromoCode string `json:"promo_code"` // 注册优惠码
InvitationCode string `json:"invitation_code"` // 邀请码
+ AffCode string `json:"aff_code"` // 邀请返利码
}
// SendVerifyCodeRequest 发送验证码请求
@@ -164,7 +165,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
- _, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode, req.InvitationCode)
+ _, user, err := h.authService.RegisterWithVerification(
+ c.Request.Context(),
+ req.Email,
+ req.Password,
+ req.VerifyCode,
+ req.PromoCode,
+ req.InvitationCode,
+ req.AffCode,
+ )
if err != nil {
response.ErrorFrom(c, err)
return
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 2affbc46..86074df7 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -108,6 +108,7 @@ type SystemSettings struct {
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
+ AffiliateRebateRate float64 `json:"affiliate_rebate_rate"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index f74c2b72..c386792c 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -14,10 +15,11 @@ import (
// UserHandler handles user-related requests
type UserHandler struct {
- userService *service.UserService
- authService *service.AuthService
- emailService *service.EmailService
- emailCache service.EmailCache
+ userService *service.UserService
+ authService *service.AuthService
+ emailService *service.EmailService
+ emailCache service.EmailCache
+ affiliateService *service.AffiliateService
}
// NewUserHandler creates a new UserHandler
@@ -35,6 +37,13 @@ func NewUserHandler(
}
}
+func (h *UserHandler) SetAffiliateService(affiliateService *service.AffiliateService) {
+ if h == nil {
+ return
+ }
+ h.affiliateService = affiliateService
+}
+
// ChangePasswordRequest represents the change password request payload
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
@@ -159,6 +168,63 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
response.Success(c, profileResp)
}
+func (h *UserHandler) affiliateServiceOrErr() (*service.AffiliateService, error) {
+ if h == nil || h.affiliateService == nil {
+ return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return h.affiliateService, nil
+}
+
+// GetAffiliate returns the current user's affiliate details.
+// GET /api/v1/user/aff
+func (h *UserHandler) GetAffiliate(c *gin.Context) {
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok {
+ response.Unauthorized(c, "User not authenticated")
+ return
+ }
+
+ affiliateSvc, err := h.affiliateServiceOrErr()
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ detail, err := affiliateSvc.GetAffiliateDetail(c.Request.Context(), subject.UserID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, detail)
+}
+
+// TransferAffiliateQuota transfers all available affiliate quota into current balance.
+// POST /api/v1/user/aff/transfer
+func (h *UserHandler) TransferAffiliateQuota(c *gin.Context) {
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok {
+ response.Unauthorized(c, "User not authenticated")
+ return
+ }
+
+ affiliateSvc, err := h.affiliateServiceOrErr()
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ transferred, balance, err := affiliateSvc.TransferAffiliateQuota(c.Request.Context(), subject.UserID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ response.Success(c, gin.H{
+ "transferred_quota": transferred,
+ "balance": balance,
+ })
+}
+
type StartIdentityBindingRequest struct {
Provider string `json:"provider" binding:"required"`
RedirectTo string `json:"redirect_to"`
diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go
index 6d175488..d4b34fd2 100644
--- a/backend/internal/handler/wire.go
+++ b/backend/internal/handler/wire.go
@@ -80,6 +80,18 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui
return NewSettingHandler(settingService, buildInfo.Version)
}
+func ProvideUserHandler(
+ userService *service.UserService,
+ authService *service.AuthService,
+ emailService *service.EmailService,
+ emailCache service.EmailCache,
+ affiliateService *service.AffiliateService,
+) *UserHandler {
+ handler := NewUserHandler(userService, authService, emailService, emailCache)
+ handler.SetAffiliateService(affiliateService)
+ return handler
+}
+
// ProvideHandlers creates the Handlers struct
func ProvideHandlers(
authHandler *AuthHandler,
@@ -125,7 +137,7 @@ func ProvideHandlers(
var ProviderSet = wire.NewSet(
// Top-level handlers
NewAuthHandler,
- NewUserHandler,
+ ProvideUserHandler,
NewAPIKeyHandler,
NewUsageHandler,
NewRedeemHandler,
diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go
new file mode 100644
index 00000000..342ddf4f
--- /dev/null
+++ b/backend/internal/repository/affiliate_repo.go
@@ -0,0 +1,420 @@
+package repository
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/lib/pq"
+)
+
+const (
+ affiliateCodeLength = 12
+ affiliateCodeMaxAttempts = 12
+)
+
+var affiliateCodeCharset = []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
+
+type affiliateQueryExecer interface {
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+type affiliateRepository struct {
+ client *dbent.Client
+}
+
+func NewAffiliateRepository(client *dbent.Client, _ *sql.DB) service.AffiliateRepository {
+ return &affiliateRepository{client: client}
+}
+
+func (r *affiliateRepository) EnsureUserAffiliate(ctx context.Context, userID int64) (*service.AffiliateSummary, error) {
+ if userID <= 0 {
+ return nil, service.ErrUserNotFound
+ }
+ client := clientFromContext(ctx, r.client)
+ return ensureUserAffiliateWithClient(ctx, client, userID)
+}
+
+func (r *affiliateRepository) GetAffiliateByCode(ctx context.Context, code string) (*service.AffiliateSummary, error) {
+ client := clientFromContext(ctx, r.client)
+ return queryAffiliateByCode(ctx, client, code)
+}
+
+func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID int64) (bool, error) {
+ var bound bool
+ err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
+ return err
+ }
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, inviterID); err != nil {
+ return err
+ }
+
+ res, err := txClient.ExecContext(txCtx,
+ "UPDATE user_affiliates SET inviter_id = $1, updated_at = NOW() WHERE user_id = $2 AND inviter_id IS NULL",
+ inviterID, userID,
+ )
+ if err != nil {
+ return fmt.Errorf("bind inviter: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ bound = false
+ return nil
+ }
+
+ if _, err = txClient.ExecContext(txCtx,
+ "UPDATE user_affiliates SET aff_count = aff_count + 1, updated_at = NOW() WHERE user_id = $1",
+ inviterID,
+ ); err != nil {
+ return fmt.Errorf("increment inviter aff_count: %w", err)
+ }
+ bound = true
+ return nil
+ })
+ if err != nil {
+ return false, err
+ }
+ return bound, nil
+}
+
+func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) {
+ if amount <= 0 {
+ return false, nil
+ }
+
+ var applied bool
+ err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ res, err := txClient.ExecContext(txCtx,
+ "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2",
+ amount, inviterID,
+ )
+ if err != nil {
+ return err
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ applied = false
+ return nil
+ }
+
+ if _, err = txClient.ExecContext(txCtx, `
+INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
+VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil {
+ return fmt.Errorf("insert affiliate accrue ledger: %w", err)
+ }
+
+ applied = true
+ return nil
+ })
+ if err != nil {
+ return false, err
+ }
+ return applied, nil
+}
+
+func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) {
+ var transferred float64
+ var newBalance float64
+
+ err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
+ if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
+ return err
+ }
+
+ rows, err := txClient.QueryContext(txCtx, `
+WITH claimed AS (
+ SELECT aff_quota::double precision AS amount
+ FROM user_affiliates
+ WHERE user_id = $1
+ AND aff_quota > 0
+ FOR UPDATE
+),
+cleared AS (
+ UPDATE user_affiliates ua
+ SET aff_quota = 0,
+ updated_at = NOW()
+ FROM claimed c
+ WHERE ua.user_id = $1
+ RETURNING c.amount
+)
+SELECT amount
+FROM cleared`, userID)
+ if err != nil {
+ return fmt.Errorf("claim affiliate quota: %w", err)
+ }
+
+ if !rows.Next() {
+ _ = rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ return service.ErrAffiliateQuotaEmpty
+ }
+ if err := rows.Scan(&transferred); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ if transferred <= 0 {
+ return service.ErrAffiliateQuotaEmpty
+ }
+
+ affected, err := txClient.User.Update().
+ Where(user.IDEQ(userID)).
+ AddBalance(transferred).
+ AddTotalRecharged(transferred).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("credit user balance by affiliate quota: %w", err)
+ }
+ if affected == 0 {
+ return service.ErrUserNotFound
+ }
+
+ newBalance, err = queryUserBalance(txCtx, txClient, userID)
+ if err != nil {
+ return err
+ }
+
+ if _, err = txClient.ExecContext(txCtx, `
+INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
+VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`, userID, transferred); err != nil {
+ return fmt.Errorf("insert affiliate transfer ledger: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return 0, 0, err
+ }
+
+ return transferred, newBalance, nil
+}
+
+func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ client := clientFromContext(ctx, r.client)
+ rows, err := client.QueryContext(ctx, `
+SELECT ua.user_id,
+ COALESCE(u.email, ''),
+ COALESCE(u.username, ''),
+ ua.created_at
+FROM user_affiliates ua
+LEFT JOIN users u ON u.id = ua.user_id
+WHERE ua.inviter_id = $1
+ORDER BY ua.created_at DESC
+LIMIT $2`, inviterID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ invitees := make([]service.AffiliateInvitee, 0)
+ for rows.Next() {
+ var item service.AffiliateInvitee
+ var createdAt time.Time
+ if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt); err != nil {
+ return nil, err
+ }
+ item.CreatedAt = &createdAt
+ invitees = append(invitees, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return invitees, nil
+}
+
+func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
+ if tx := dbent.TxFromContext(ctx); tx != nil {
+ return fn(ctx, tx.Client())
+ }
+
+ tx, err := r.client.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin affiliate transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ if err := fn(txCtx, tx.Client()); err != nil {
+ return err
+ }
+
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit affiliate transaction: %w", err)
+ }
+ return nil
+}
+
+func ensureUserAffiliateWithClient(ctx context.Context, client affiliateQueryExecer, userID int64) (*service.AffiliateSummary, error) {
+ summary, err := queryAffiliateByUserID(ctx, client, userID)
+ if err == nil {
+ return summary, nil
+ }
+ if !errors.Is(err, service.ErrAffiliateProfileNotFound) {
+ return nil, err
+ }
+
+ for i := 0; i < affiliateCodeMaxAttempts; i++ {
+ code, codeErr := generateAffiliateCode()
+ if codeErr != nil {
+ return nil, codeErr
+ }
+ _, insertErr := client.ExecContext(ctx, `
+INSERT INTO user_affiliates (user_id, aff_code, created_at, updated_at)
+VALUES ($1, $2, NOW(), NOW())
+ON CONFLICT (user_id) DO NOTHING`, userID, code)
+ if insertErr == nil {
+ break
+ }
+ if isAffiliateUniqueViolation(insertErr) {
+ continue
+ }
+ return nil, insertErr
+ }
+
+ return queryAffiliateByUserID(ctx, client, userID)
+}
+
+func queryAffiliateByUserID(ctx context.Context, client affiliateQueryExecer, userID int64) (*service.AffiliateSummary, error) {
+ rows, err := client.QueryContext(ctx, `
+SELECT user_id,
+ aff_code,
+ inviter_id,
+ aff_count,
+ aff_quota::double precision,
+ aff_history_quota::double precision,
+ created_at,
+ updated_at
+FROM user_affiliates
+WHERE user_id = $1`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nil, service.ErrAffiliateProfileNotFound
+ }
+
+ var out service.AffiliateSummary
+ var inviterID sql.NullInt64
+ if err := rows.Scan(
+ &out.UserID,
+ &out.AffCode,
+ &inviterID,
+ &out.AffCount,
+ &out.AffQuota,
+ &out.AffHistoryQuota,
+ &out.CreatedAt,
+ &out.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ if inviterID.Valid {
+ out.InviterID = &inviterID.Int64
+ }
+ return &out, nil
+}
+
+func queryAffiliateByCode(ctx context.Context, client affiliateQueryExecer, code string) (*service.AffiliateSummary, error) {
+ rows, err := client.QueryContext(ctx, `
+SELECT user_id,
+ aff_code,
+ inviter_id,
+ aff_count,
+ aff_quota::double precision,
+ aff_history_quota::double precision,
+ created_at,
+ updated_at
+FROM user_affiliates
+WHERE aff_code = $1
+LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nil, service.ErrAffiliateProfileNotFound
+ }
+
+ var out service.AffiliateSummary
+ var inviterID sql.NullInt64
+ if err := rows.Scan(
+ &out.UserID,
+ &out.AffCode,
+ &inviterID,
+ &out.AffCount,
+ &out.AffQuota,
+ &out.AffHistoryQuota,
+ &out.CreatedAt,
+ &out.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ if inviterID.Valid {
+ out.InviterID = &inviterID.Int64
+ }
+ return &out, nil
+}
+
+func queryUserBalance(ctx context.Context, client affiliateQueryExecer, userID int64) (float64, error) {
+ rows, err := client.QueryContext(ctx,
+ "SELECT balance::double precision FROM users WHERE id = $1 LIMIT 1",
+ userID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = rows.Close() }()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ return 0, service.ErrUserNotFound
+ }
+ var balance float64
+ if err := rows.Scan(&balance); err != nil {
+ return 0, err
+ }
+ return balance, nil
+}
+
+func generateAffiliateCode() (string, error) {
+ buf := make([]byte, affiliateCodeLength)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate affiliate code: %w", err)
+ }
+ for i := range buf {
+ buf[i] = affiliateCodeCharset[int(buf[i])%len(affiliateCodeCharset)]
+ }
+ return string(buf), nil
+}
+
+func isAffiliateUniqueViolation(err error) bool {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) {
+ return string(pqErr.Code) == "23505"
+ }
+ return false
+}
diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go
new file mode 100644
index 00000000..3ab5c0fb
--- /dev/null
+++ b/backend/internal/repository/affiliate_repo_integration_test.go
@@ -0,0 +1,114 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func querySingleFloat(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) float64 {
+ t.Helper()
+ rows, err := client.QueryContext(ctx, query, args...)
+ require.NoError(t, err)
+ defer func() { _ = rows.Close() }()
+
+ require.True(t, rows.Next(), "expected one row")
+ var value float64
+ require.NoError(t, rows.Scan(&value))
+ require.NoError(t, rows.Err())
+ return value
+}
+
+func querySingleInt(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) int {
+ t.Helper()
+ rows, err := client.QueryContext(ctx, query, args...)
+ require.NoError(t, err)
+ defer func() { _ = rows.Close() }()
+
+ require.True(t, rows.Next(), "expected one row")
+ var value int
+ require.NoError(t, rows.Scan(&value))
+ require.NoError(t, rows.Err())
+ return value
+}
+
+func TestAffiliateRepository_TransferQuotaToBalance_UsesClaimedQuotaBeforeClear(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-transfer-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 5.5,
+ Concurrency: 5,
+ })
+
+ affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
+ _, err := client.ExecContext(txCtx, `
+INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
+VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
+ require.NoError(t, err)
+
+ transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
+ require.NoError(t, err)
+ require.InDelta(t, 12.34, transferred, 1e-9)
+ require.InDelta(t, 17.84, balance, 1e-9)
+
+ affQuota := querySingleFloat(t, txCtx, client,
+ "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID)
+ require.InDelta(t, 0.0, affQuota, 1e-9)
+
+ persistedBalance := querySingleFloat(t, txCtx, client,
+ "SELECT balance::double precision FROM users WHERE id = $1", u.ID)
+ require.InDelta(t, 17.84, persistedBalance, 1e-9)
+
+ ledgerCount := querySingleInt(t, txCtx, client,
+ "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
+ require.Equal(t, 1, ledgerCount)
+}
+
+func TestAffiliateRepository_TransferQuotaToBalance_EmptyQuota(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-empty-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 3.21,
+ Concurrency: 5,
+ })
+
+ affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
+ _, err := client.ExecContext(txCtx, `
+INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
+VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
+ require.NoError(t, err)
+
+ transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
+ require.ErrorIs(t, err, service.ErrAffiliateQuotaEmpty)
+ require.InDelta(t, 0.0, transferred, 1e-9)
+ require.InDelta(t, 0.0, balance, 1e-9)
+
+ persistedBalance := querySingleFloat(t, txCtx, client,
+ "SELECT balance::double precision FROM users WHERE id = $1", u.ID)
+ require.InDelta(t, 3.21, persistedBalance, 1e-9)
+}
diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go
index 6d24d312..f07bbb33 100644
--- a/backend/internal/repository/wire.go
+++ b/backend/internal/repository/wire.go
@@ -91,6 +91,7 @@ var ProviderSet = wire.NewSet(
NewChannelRepository,
NewChannelMonitorRepository,
NewChannelMonitorRequestTemplateRepository,
+ NewAffiliateRepository,
// Cache implementations
NewGatewayCache,
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index e89ef3d9..35a6524a 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -715,6 +715,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
+ "affiliate_rebate_rate": 20,
"default_user_rpm_limit": 0,
"default_subscriptions": [],
"enable_model_fallback": false,
@@ -895,6 +896,7 @@ func TestAPIContracts(t *testing.T) {
"custom_endpoints": [],
"default_concurrency": 0,
"default_balance": 0,
+ "affiliate_rebate_rate": 20,
"default_user_rpm_limit": 0,
"default_subscriptions": [],
"enable_model_fallback": false,
diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go
index babab125..9976954c 100644
--- a/backend/internal/server/routes/user.go
+++ b/backend/internal/server/routes/user.go
@@ -25,6 +25,8 @@ func RegisterUserRoutes(
user.GET("/profile", h.User.GetProfile)
user.PUT("/password", h.User.ChangePassword)
user.PUT("", h.User.UpdateProfile)
+ user.GET("/aff", h.User.GetAffiliate)
+ user.POST("/aff/transfer", h.User.TransferAffiliateQuota)
user.POST("/account-bindings/email/send-code", h.User.SendEmailBindingCode)
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go
new file mode 100644
index 00000000..6fa5b423
--- /dev/null
+++ b/backend/internal/service/affiliate_service.go
@@ -0,0 +1,288 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+var (
+ ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
+ ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
+ 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
+)
+
+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"`
+}
+
+type AffiliateInvitee struct {
+ UserID int64 `json:"user_id"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+ CreatedAt *time.Time `json:"created_at,omitempty"`
+}
+
+type AffiliateDetail 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"`
+ Invitees []AffiliateInvitee `json:"invitees"`
+}
+
+type AffiliateRepository interface {
+ EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
+ GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
+ BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
+ 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)
+}
+
+type AffiliateService struct {
+ repo AffiliateRepository
+ settingRepo SettingRepository
+ authCacheInvalidator APIKeyAuthCacheInvalidator
+ billingCacheService *BillingCacheService
+}
+
+func NewAffiliateService(repo AffiliateRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
+ return &AffiliateService{
+ repo: repo,
+ settingRepo: settingRepo,
+ authCacheInvalidator: authCacheInvalidator,
+ billingCacheService: billingCacheService,
+ }
+}
+
+func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
+ if userID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
+ }
+ if s == nil || s.repo == nil {
+ return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.EnsureUserAffiliate(ctx, userID)
+}
+
+func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) {
+ summary, err := s.EnsureUserAffiliate(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ invitees, err := s.listInvitees(ctx, userID)
+ if err != nil {
+ 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,
+ }, nil
+}
+
+func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64, rawCode string) error {
+ code := strings.ToUpper(strings.TrimSpace(rawCode))
+ if code == "" {
+ return nil
+ }
+ if s == nil || s.repo == nil {
+ return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+
+ selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if selfSummary.InviterID != nil {
+ return nil
+ }
+
+ inviterSummary, err := s.repo.GetAffiliateByCode(ctx, code)
+ if err != nil {
+ if errors.Is(err, ErrAffiliateProfileNotFound) {
+ return ErrAffiliateCodeInvalid
+ }
+ return err
+ }
+ if inviterSummary == nil || inviterSummary.UserID <= 0 || inviterSummary.UserID == userID {
+ return ErrAffiliateCodeInvalid
+ }
+
+ bound, err := s.repo.BindInviter(ctx, userID, inviterSummary.UserID)
+ if err != nil {
+ return err
+ }
+ if !bound {
+ return ErrAffiliateAlreadyBound
+ }
+ return nil
+}
+
+func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
+ if s == nil || s.repo == nil {
+ return 0, nil
+ }
+ if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
+ return 0, nil
+ }
+
+ inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
+ if err != nil {
+ return 0, err
+ }
+ if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 {
+ return 0, nil
+ }
+
+ rebateRatePercent := s.loadAffiliateRebateRatePercent(ctx)
+ 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
+ }
+ if !applied {
+ return 0, nil
+ }
+ return rebate, nil
+}
+
+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")
+ }
+
+ transferred, balance, err := s.repo.TransferQuotaToBalance(ctx, userID)
+ if err != nil {
+ return 0, 0, err
+ }
+ if transferred > 0 {
+ s.invalidateAffiliateCaches(ctx, userID)
+ }
+ return transferred, balance, nil
+}
+
+func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([]AffiliateInvitee, error) {
+ if s == nil || s.repo == nil {
+ return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ invitees, err := s.repo.ListInvitees(ctx, inviterID, affiliateInviteesLimit)
+ if err != nil {
+ return nil, err
+ }
+ for i := range invitees {
+ invitees[i].Email = maskEmail(invitees[i].Email)
+ }
+ 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
+}
+
+func maskEmail(email string) string {
+ email = strings.TrimSpace(email)
+ if email == "" {
+ return ""
+ }
+ at := strings.Index(email, "@")
+ if at <= 0 || at >= len(email)-1 {
+ return "***"
+ }
+
+ local := email[:at]
+ domain := email[at+1:]
+ dot := strings.LastIndex(domain, ".")
+
+ maskedLocal := maskSegment(local)
+ if dot <= 0 || dot >= len(domain)-1 {
+ return maskedLocal + "@" + maskSegment(domain)
+ }
+
+ domainName := domain[:dot]
+ tld := domain[dot:]
+ return maskedLocal + "@" + maskSegment(domainName) + tld
+}
+
+func maskSegment(s string) string {
+ r := []rune(s)
+ if len(r) == 0 {
+ return "***"
+ }
+ if len(r) == 1 {
+ return string(r[0]) + "***"
+ }
+ return string(r[0]) + "***"
+}
+
+func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID int64) {
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
+ }
+ if s.billingCacheService != nil {
+ go func() {
+ cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
+ }()
+ }
+}
diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go
new file mode 100644
index 00000000..6adf879d
--- /dev/null
+++ b/backend/internal/service/affiliate_service_test.go
@@ -0,0 +1,59 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "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) {
+ t.Parallel()
+
+ svc := &AffiliateService{settingRepo: &affiliateSettingRepoStub{value: "1"}}
+ rate := svc.loadAffiliateRebateRatePercent(context.Background())
+ require.Equal(t, 1.0, rate)
+
+ svc.settingRepo = &affiliateSettingRepoStub{value: "0.2"}
+ rate = svc.loadAffiliateRebateRatePercent(context.Background())
+ require.Equal(t, 0.2, rate)
+}
+
+func TestMaskEmail(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, "a***@g***.com", maskEmail("alice@gmail.com"))
+ require.Equal(t, "x***@d***", maskEmail("x@domain"))
+ require.Equal(t, "", maskEmail(""))
+}
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
index e45d8d66..fe0c32f5 100644
--- a/backend/internal/service/auth_service.go
+++ b/backend/internal/service/auth_service.go
@@ -72,6 +72,7 @@ type AuthService struct {
turnstileService *TurnstileService
emailQueueService *EmailQueueService
promoService *PromoService
+ affiliateService *AffiliateService
defaultSubAssigner DefaultSubscriptionAssigner
}
@@ -121,13 +122,26 @@ func (s *AuthService) EntClient() *dbent.Client {
return s.entClient
}
+func (s *AuthService) SetAffiliateService(affiliateService *AffiliateService) {
+ if s == nil {
+ return
+ }
+ s.affiliateService = affiliateService
+}
+
// Register 用户注册,返回token和用户
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
return s.RegisterWithVerification(ctx, email, password, "", "", "")
}
-// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户
-func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) {
+// RegisterWithVerification 用户注册(支持邮件验证、优惠码、邀请码和邀请返利码),返回token和用户。
+// affiliateCode 使用可选参数以兼容旧调用方。
+func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string, affiliateCode ...string) (string, *User, error) {
+ affiliateCodeRaw := ""
+ if len(affiliateCode) > 0 {
+ affiliateCodeRaw = affiliateCode[0]
+ }
+
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return "", nil, ErrRegDisabled
@@ -223,6 +237,17 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
}
s.postAuthUserBootstrap(ctx, user, "email", true)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
+ if s.affiliateService != nil {
+ if _, err := s.affiliateService.EnsureUserAffiliate(ctx, user.ID); err != nil {
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", user.ID, err)
+ }
+ if code := strings.TrimSpace(affiliateCodeRaw); code != "" {
+ if err := s.affiliateService.BindInviterByCode(ctx, user.ID, code); err != nil {
+ // 邀请返利码绑定失败不影响注册,只记录日志
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", user.ID, err)
+ }
+ }
+ }
// 标记邀请码为已使用(如果使用了邀请码)
if invitationRedeemCode != nil {
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index cf47b76f..23afeb87 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -18,6 +18,13 @@ const (
RoleUser = domain.RoleUser
)
+// Affiliate rebate settings
+const (
+ AffiliateRebateRateDefault = 20.0
+ AffiliateRebateRateMin = 0.0
+ AffiliateRebateRateMax = 100.0
+)
+
// Platform constants
const (
PlatformAnthropic = domain.PlatformAnthropic
@@ -87,6 +94,7 @@ const (
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
+ SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
// 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index 243edff3..c6167447 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -268,6 +269,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
switch action {
case redeemActionSkipCompleted:
+ s.applyAffiliateRebateForOrder(ctx, o)
// Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
case redeemActionCreate:
@@ -281,6 +283,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)
}
+ s.applyAffiliateRebateForOrder(ctx, o)
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
}
@@ -358,6 +361,139 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action
return c > 0
}
+func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) {
+ if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 {
+ return
+ }
+ if s.affiliateService == nil {
+ return
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": fmt.Sprintf("begin affiliate rebate tx: %v", err),
+ })
+ return
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ claimed, err := s.tryClaimAffiliateRebateAudit(txCtx, tx.Client(), o.ID, o.Amount)
+ if err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+ if !claimed {
+ return
+ }
+
+ rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
+ if err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ if rebateAmount <= 0 {
+ if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_SKIPPED", map[string]any{
+ "baseAmount": o.Amount,
+ "reason": "no inviter bound or rebate amount <= 0",
+ }); err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
+ })
+ }
+ return
+ }
+
+ if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{
+ "baseAmount": o.Amount,
+ "rebateAmount": rebateAmount,
+ }); err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
+ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
+ })
+ }
+}
+
+func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) {
+ if client == nil {
+ return false, errors.New("nil payment client")
+ }
+ oid := strconv.FormatInt(orderID, 10)
+ detail, _ := json.Marshal(map[string]any{
+ "baseAmount": baseAmount,
+ "status": "reserved",
+ })
+ rows, err := client.QueryContext(ctx, `
+INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
+SELECT $1, 'AFFILIATE_REBATE_APPLIED', $2, 'system', NOW()
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM payment_audit_logs
+ WHERE order_id = $1
+ AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
+)
+ON CONFLICT (order_id, action) DO NOTHING
+RETURNING id`, oid, string(detail))
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = rows.Close() }()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return false, err
+ }
+ return false, nil
+ }
+ var claimID int64
+ if err := rows.Scan(&claimID); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+func (s *PaymentService) updateClaimedAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, action string, detail map[string]any) error {
+ if client == nil {
+ return errors.New("nil payment client")
+ }
+ oid := strconv.FormatInt(orderID, 10)
+ detailJSON, _ := json.Marshal(detail)
+ updated, err := client.PaymentAuditLog.Update().
+ Where(
+ paymentauditlog.OrderIDEQ(oid),
+ paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED"),
+ ).
+ SetAction(action).
+ SetDetail(string(detailJSON)).
+ SetOperator("system").
+ Save(ctx)
+ if err != nil {
+ return err
+ }
+ if updated == 0 {
+ return errors.New("affiliate rebate claim log not found")
+ }
+ return nil
+}
+
func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
now := time.Now()
r := psErrMsg(cause)
diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go
index 97fd76a0..15f6feeb 100644
--- a/backend/internal/service/payment_service.go
+++ b/backend/internal/service/payment_service.go
@@ -170,17 +170,18 @@ type TopUserStat struct {
// --- Service ---
type PaymentService struct {
- providerMu sync.Mutex
- providersLoaded bool
- entClient *dbent.Client
- registry *payment.Registry
- loadBalancer payment.LoadBalancer
- redeemService *RedeemService
- subscriptionSvc *SubscriptionService
- configService *PaymentConfigService
- userRepo UserRepository
- groupRepo GroupRepository
- resumeService *PaymentResumeService
+ providerMu sync.Mutex
+ providersLoaded bool
+ entClient *dbent.Client
+ registry *payment.Registry
+ loadBalancer payment.LoadBalancer
+ redeemService *RedeemService
+ subscriptionSvc *SubscriptionService
+ configService *PaymentConfigService
+ userRepo UserRepository
+ groupRepo GroupRepository
+ resumeService *PaymentResumeService
+ affiliateService *AffiliateService
}
func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository) *PaymentService {
@@ -189,6 +190,13 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load
return svc
}
+func (s *PaymentService) SetAffiliateService(affiliateService *AffiliateService) {
+ if s == nil {
+ return
+ }
+ s.affiliateService = affiliateService
+}
+
// --- Provider Registry ---
// EnsureProviders lazily initializes the provider registry on first call.
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index c79d8949..f3801c48 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "math"
"net/url"
"sort"
"strconv"
@@ -1167,6 +1168,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
+ settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
+ updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64)
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
if err != nil {
@@ -1719,6 +1722,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOIDCConnectUserInfoUsernamePath: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
+ SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0",
@@ -1846,6 +1850,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else {
result.DefaultBalance = s.cfg.Default.UserBalance
}
+ if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil {
+ result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate)
+ } else {
+ result.AffiliateRebateRate = AffiliateRebateRateDefault
+ }
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
// 敏感信息直接返回,方便测试连接时使用
@@ -2130,6 +2139,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return result
}
+func clampAffiliateRebateRate(value float64) float64 {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return AffiliateRebateRateDefault
+ }
+ if value < AffiliateRebateRateMin {
+ return AffiliateRebateRateMin
+ }
+ if value > AffiliateRebateRateMax {
+ return AffiliateRebateRateMax
+ }
+ return value
+}
+
func isFalseSettingValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "false", "0", "off", "disabled":
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index ddd4fff6..8a3bd421 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
+ AffiliateRebateRate float64
DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 86bfc327..d8a6a332 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -391,6 +391,53 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
return svc
}
+func ProvideAuthService(
+ entClient *dbent.Client,
+ userRepo UserRepository,
+ redeemRepo RedeemCodeRepository,
+ refreshTokenCache RefreshTokenCache,
+ cfg *config.Config,
+ settingService *SettingService,
+ emailService *EmailService,
+ turnstileService *TurnstileService,
+ emailQueueService *EmailQueueService,
+ promoService *PromoService,
+ defaultSubAssigner DefaultSubscriptionAssigner,
+ affiliateService *AffiliateService,
+) *AuthService {
+ svc := NewAuthService(
+ entClient,
+ userRepo,
+ redeemRepo,
+ refreshTokenCache,
+ cfg,
+ settingService,
+ emailService,
+ turnstileService,
+ emailQueueService,
+ promoService,
+ defaultSubAssigner,
+ )
+ svc.SetAffiliateService(affiliateService)
+ return svc
+}
+
+func ProvidePaymentService(
+ entClient *dbent.Client,
+ registry *payment.Registry,
+ loadBalancer payment.LoadBalancer,
+ redeemService *RedeemService,
+ subscriptionSvc *SubscriptionService,
+ configService *PaymentConfigService,
+ userRepo UserRepository,
+ groupRepo GroupRepository,
+ affiliateService *AffiliateService,
+) *PaymentService {
+ svc := NewPaymentService(entClient, registry, loadBalancer, redeemService, subscriptionSvc, configService, userRepo, groupRepo)
+ svc.SetAffiliateService(affiliateService)
+ return svc
+}
+
// ProvideBillingCacheService wires BillingCacheService with its RPM dependencies.
func ProvideBillingCacheService(
cache BillingCache,
@@ -407,7 +454,7 @@ func ProvideBillingCacheService(
// ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet(
// Core services
- NewAuthService,
+ ProvideAuthService,
NewUserService,
NewAPIKeyService,
ProvideAPIKeyAuthCacheInvalidator,
@@ -486,8 +533,9 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService,
NewChannelService,
NewModelPricingResolver,
+ NewAffiliateService,
ProvidePaymentConfigService,
- NewPaymentService,
+ ProvidePaymentService,
ProvidePaymentOrderExpiryService,
ProvideBalanceNotifyService,
ProvideChannelMonitorService,
diff --git a/backend/migrations/130_add_user_affiliates.sql b/backend/migrations/130_add_user_affiliates.sql
new file mode 100644
index 00000000..d8c001e0
--- /dev/null
+++ b/backend/migrations/130_add_user_affiliates.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS user_affiliates (
+ user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ aff_code VARCHAR(32) NOT NULL UNIQUE,
+ inviter_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL,
+ aff_count INTEGER NOT NULL DEFAULT 0,
+ aff_quota DECIMAL(20,8) NOT NULL DEFAULT 0,
+ aff_history_quota DECIMAL(20,8) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_affiliates_inviter_id ON user_affiliates(inviter_id);
+CREATE INDEX IF NOT EXISTS idx_user_affiliates_aff_quota ON user_affiliates(aff_quota);
+
+COMMENT ON TABLE user_affiliates IS '用户邀请返利信息';
+COMMENT ON COLUMN user_affiliates.aff_code IS '用户邀请代码';
+COMMENT ON COLUMN user_affiliates.inviter_id IS '邀请人用户ID';
+COMMENT ON COLUMN user_affiliates.aff_count IS '累计邀请人数';
+COMMENT ON COLUMN user_affiliates.aff_quota IS '当前可提取返利金额';
+COMMENT ON COLUMN user_affiliates.aff_history_quota IS '累计返利历史金额';
diff --git a/backend/migrations/131_affiliate_rebate_hardening.sql b/backend/migrations/131_affiliate_rebate_hardening.sql
new file mode 100644
index 00000000..81e37a9e
--- /dev/null
+++ b/backend/migrations/131_affiliate_rebate_hardening.sql
@@ -0,0 +1,58 @@
+-- 1) Normalize historical affiliate rebate rate values.
+-- Legacy compatibility treated 0
+ {{ t("admin.settings.defaults.affiliateRebateRateHint") }} +
+