Merge pull request #1897 from VpSanta33/codex/invite-affiliate-rebate
feat: 新增邀请返利功能,并支持后台配置返利比例
This commit is contained in:
@@ -69,7 +69,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
||||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
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)
|
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||||
redeemCache := repository.NewRedeemCache(redisClient)
|
redeemCache := repository.NewRedeemCache(redisClient)
|
||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
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)
|
totpCache := repository.NewTotpCache(redisClient)
|
||||||
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
|
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
|
||||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
|
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)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||||
@@ -91,6 +93,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
|
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
|
||||||
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
|
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
|
||||||
announcementHandler := handler.NewAnnouncementHandler(announcementService)
|
announcementHandler := handler.NewAnnouncementHandler(announcementService)
|
||||||
|
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
|
||||||
|
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
|
||||||
|
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
|
||||||
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
||||||
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||||
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, 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)
|
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
|
||||||
registry := payment.ProvideRegistry()
|
registry := payment.ProvideRegistry()
|
||||||
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
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)
|
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
|
||||||
opsHandler := admin.NewOpsHandler(opsService)
|
opsHandler := admin.NewOpsHandler(opsService)
|
||||||
updateCache := repository.NewUpdateCache(redisClient)
|
updateCache := repository.NewUpdateCache(redisClient)
|
||||||
@@ -221,20 +226,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
||||||
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
||||||
channelHandler := admin.NewChannelHandler(channelService, billingService)
|
channelHandler := admin.NewChannelHandler(channelService, billingService)
|
||||||
sqlDB, err := repository.ProvideSQLDB(client)
|
channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
|
||||||
if err != nil {
|
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
channelMonitorRepository := repository.NewChannelMonitorRepository(client, sqlDB)
|
|
||||||
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, sqlDB)
|
|
||||||
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
||||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
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)
|
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)
|
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)
|
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||||
@@ -245,9 +241,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
totpHandler := handler.NewTotpHandler(totpService)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||||
|
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(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)
|
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||||
@@ -263,6 +260,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
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)
|
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{
|
application := &Application{
|
||||||
Server: httpServer,
|
Server: httpServer,
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
DefaultBalance: settings.DefaultBalance,
|
DefaultBalance: settings.DefaultBalance,
|
||||||
|
AffiliateRebateRate: settings.AffiliateRebateRate,
|
||||||
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
|
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
|
||||||
DefaultSubscriptions: defaultSubscriptions,
|
DefaultSubscriptions: defaultSubscriptions,
|
||||||
EnableModelFallback: settings.EnableModelFallback,
|
EnableModelFallback: settings.EnableModelFallback,
|
||||||
@@ -338,6 +339,7 @@ type UpdateSettingsRequest struct {
|
|||||||
// 默认配置
|
// 默认配置
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
DefaultBalance float64 `json:"default_balance"`
|
DefaultBalance float64 `json:"default_balance"`
|
||||||
|
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
|
||||||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||||||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||||
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
||||||
@@ -468,6 +470,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
if req.DefaultBalance < 0 {
|
if req.DefaultBalance < 0 {
|
||||||
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 {
|
if req.TableDefaultPageSize <= 0 {
|
||||||
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
|
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
|
||||||
@@ -1119,6 +1131,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
CustomEndpoints: customEndpointsJSON,
|
CustomEndpoints: customEndpointsJSON,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
DefaultBalance: req.DefaultBalance,
|
DefaultBalance: req.DefaultBalance,
|
||||||
|
AffiliateRebateRate: affiliateRebateRate,
|
||||||
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
|
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
|
||||||
DefaultSubscriptions: defaultSubscriptions,
|
DefaultSubscriptions: defaultSubscriptions,
|
||||||
EnableModelFallback: req.EnableModelFallback,
|
EnableModelFallback: req.EnableModelFallback,
|
||||||
@@ -1433,6 +1446,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
|
||||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||||
DefaultBalance: updatedSettings.DefaultBalance,
|
DefaultBalance: updatedSettings.DefaultBalance,
|
||||||
|
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
|
||||||
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
|
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
|
||||||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||||||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||||||
@@ -1738,6 +1752,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.DefaultBalance != after.DefaultBalance {
|
if before.DefaultBalance != after.DefaultBalance {
|
||||||
changed = append(changed, "default_balance")
|
changed = append(changed, "default_balance")
|
||||||
}
|
}
|
||||||
|
if before.AffiliateRebateRate != after.AffiliateRebateRate {
|
||||||
|
changed = append(changed, "affiliate_rebate_rate")
|
||||||
|
}
|
||||||
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
|
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
|
||||||
changed = append(changed, "default_subscriptions")
|
changed = append(changed, "default_subscriptions")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type RegisterRequest struct {
|
|||||||
TurnstileToken string `json:"turnstile_token"`
|
TurnstileToken string `json:"turnstile_token"`
|
||||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||||
InvitationCode string `json:"invitation_code"` // 邀请码
|
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||||
|
AffCode string `json:"aff_code"` // 邀请返利码
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCodeRequest 发送验证码请求
|
// SendVerifyCodeRequest 发送验证码请求
|
||||||
@@ -164,7 +165,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
DefaultBalance float64 `json:"default_balance"`
|
DefaultBalance float64 `json:"default_balance"`
|
||||||
|
AffiliateRebateRate float64 `json:"affiliate_rebate_rate"`
|
||||||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||||||
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
|
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"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"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -18,6 +19,7 @@ type UserHandler struct {
|
|||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
emailService *service.EmailService
|
emailService *service.EmailService
|
||||||
emailCache service.EmailCache
|
emailCache service.EmailCache
|
||||||
|
affiliateService *service.AffiliateService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserHandler creates a new UserHandler
|
// 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
|
// ChangePasswordRequest represents the change password request payload
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
OldPassword string `json:"old_password" binding:"required"`
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
@@ -159,6 +168,63 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|||||||
response.Success(c, profileResp)
|
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 {
|
type StartIdentityBindingRequest struct {
|
||||||
Provider string `json:"provider" binding:"required"`
|
Provider string `json:"provider" binding:"required"`
|
||||||
RedirectTo string `json:"redirect_to"`
|
RedirectTo string `json:"redirect_to"`
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui
|
|||||||
return NewSettingHandler(settingService, buildInfo.Version)
|
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
|
// ProvideHandlers creates the Handlers struct
|
||||||
func ProvideHandlers(
|
func ProvideHandlers(
|
||||||
authHandler *AuthHandler,
|
authHandler *AuthHandler,
|
||||||
@@ -125,7 +137,7 @@ func ProvideHandlers(
|
|||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
// Top-level handlers
|
// Top-level handlers
|
||||||
NewAuthHandler,
|
NewAuthHandler,
|
||||||
NewUserHandler,
|
ProvideUserHandler,
|
||||||
NewAPIKeyHandler,
|
NewAPIKeyHandler,
|
||||||
NewUsageHandler,
|
NewUsageHandler,
|
||||||
NewRedeemHandler,
|
NewRedeemHandler,
|
||||||
|
|||||||
420
backend/internal/repository/affiliate_repo.go
Normal file
420
backend/internal/repository/affiliate_repo.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
114
backend/internal/repository/affiliate_repo_integration_test.go
Normal file
114
backend/internal/repository/affiliate_repo_integration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewChannelRepository,
|
NewChannelRepository,
|
||||||
NewChannelMonitorRepository,
|
NewChannelMonitorRepository,
|
||||||
NewChannelMonitorRequestTemplateRepository,
|
NewChannelMonitorRequestTemplateRepository,
|
||||||
|
NewAffiliateRepository,
|
||||||
|
|
||||||
// Cache implementations
|
// Cache implementations
|
||||||
NewGatewayCache,
|
NewGatewayCache,
|
||||||
|
|||||||
@@ -715,6 +715,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"force_email_on_third_party_signup": false,
|
"force_email_on_third_party_signup": false,
|
||||||
"default_concurrency": 5,
|
"default_concurrency": 5,
|
||||||
"default_balance": 1.25,
|
"default_balance": 1.25,
|
||||||
|
"affiliate_rebate_rate": 20,
|
||||||
"default_user_rpm_limit": 0,
|
"default_user_rpm_limit": 0,
|
||||||
"default_subscriptions": [],
|
"default_subscriptions": [],
|
||||||
"enable_model_fallback": false,
|
"enable_model_fallback": false,
|
||||||
@@ -895,6 +896,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"custom_endpoints": [],
|
"custom_endpoints": [],
|
||||||
"default_concurrency": 0,
|
"default_concurrency": 0,
|
||||||
"default_balance": 0,
|
"default_balance": 0,
|
||||||
|
"affiliate_rebate_rate": 20,
|
||||||
"default_user_rpm_limit": 0,
|
"default_user_rpm_limit": 0,
|
||||||
"default_subscriptions": [],
|
"default_subscriptions": [],
|
||||||
"enable_model_fallback": false,
|
"enable_model_fallback": false,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ func RegisterUserRoutes(
|
|||||||
user.GET("/profile", h.User.GetProfile)
|
user.GET("/profile", h.User.GetProfile)
|
||||||
user.PUT("/password", h.User.ChangePassword)
|
user.PUT("/password", h.User.ChangePassword)
|
||||||
user.PUT("", h.User.UpdateProfile)
|
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/send-code", h.User.SendEmailBindingCode)
|
||||||
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
|
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
|
||||||
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
|
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
|
||||||
|
|||||||
288
backend/internal/service/affiliate_service.go
Normal file
288
backend/internal/service/affiliate_service.go
Normal file
@@ -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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/internal/service/affiliate_service_test.go
Normal file
59
backend/internal/service/affiliate_service_test.go
Normal file
@@ -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(""))
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ type AuthService struct {
|
|||||||
turnstileService *TurnstileService
|
turnstileService *TurnstileService
|
||||||
emailQueueService *EmailQueueService
|
emailQueueService *EmailQueueService
|
||||||
promoService *PromoService
|
promoService *PromoService
|
||||||
|
affiliateService *AffiliateService
|
||||||
defaultSubAssigner DefaultSubscriptionAssigner
|
defaultSubAssigner DefaultSubscriptionAssigner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +122,26 @@ func (s *AuthService) EntClient() *dbent.Client {
|
|||||||
return s.entClient
|
return s.entClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetAffiliateService(affiliateService *AffiliateService) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.affiliateService = affiliateService
|
||||||
|
}
|
||||||
|
|
||||||
// Register 用户注册,返回token和用户
|
// Register 用户注册,返回token和用户
|
||||||
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
||||||
return s.RegisterWithVerification(ctx, email, password, "", "", "")
|
return s.RegisterWithVerification(ctx, email, password, "", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户
|
// RegisterWithVerification 用户注册(支持邮件验证、优惠码、邀请码和邀请返利码),返回token和用户。
|
||||||
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) {
|
// 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 未配置时不允许注册)
|
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
|
||||||
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||||
return "", nil, ErrRegDisabled
|
return "", nil, ErrRegDisabled
|
||||||
@@ -223,6 +237,17 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
|||||||
}
|
}
|
||||||
s.postAuthUserBootstrap(ctx, user, "email", true)
|
s.postAuthUserBootstrap(ctx, user, "email", true)
|
||||||
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
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 {
|
if invitationRedeemCode != nil {
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ const (
|
|||||||
RoleUser = domain.RoleUser
|
RoleUser = domain.RoleUser
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Affiliate rebate settings
|
||||||
|
const (
|
||||||
|
AffiliateRebateRateDefault = 20.0
|
||||||
|
AffiliateRebateRateMin = 0.0
|
||||||
|
AffiliateRebateRateMax = 100.0
|
||||||
|
)
|
||||||
|
|
||||||
// Platform constants
|
// Platform constants
|
||||||
const (
|
const (
|
||||||
PlatformAnthropic = domain.PlatformAnthropic
|
PlatformAnthropic = domain.PlatformAnthropic
|
||||||
@@ -87,6 +94,7 @@ const (
|
|||||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||||
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
|
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
|
||||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||||
|
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
|
||||||
|
|
||||||
// 邮件服务设置
|
// 邮件服务设置
|
||||||
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -268,6 +269,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
|
|||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case redeemActionSkipCompleted:
|
case redeemActionSkipCompleted:
|
||||||
|
s.applyAffiliateRebateForOrder(ctx, o)
|
||||||
// Code already created and redeemed — just mark completed
|
// Code already created and redeemed — just mark completed
|
||||||
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
||||||
case redeemActionCreate:
|
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 {
|
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
|
||||||
return fmt.Errorf("redeem balance: %w", err)
|
return fmt.Errorf("redeem balance: %w", err)
|
||||||
}
|
}
|
||||||
|
s.applyAffiliateRebateForOrder(ctx, o)
|
||||||
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +361,139 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action
|
|||||||
return c > 0
|
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) {
|
func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r := psErrMsg(cause)
|
r := psErrMsg(cause)
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ type PaymentService struct {
|
|||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
groupRepo GroupRepository
|
groupRepo GroupRepository
|
||||||
resumeService *PaymentResumeService
|
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 {
|
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
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PaymentService) SetAffiliateService(affiliateService *AffiliateService) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.affiliateService = affiliateService
|
||||||
|
}
|
||||||
|
|
||||||
// --- Provider Registry ---
|
// --- Provider Registry ---
|
||||||
|
|
||||||
// EnsureProviders lazily initializes the provider registry on first call.
|
// EnsureProviders lazily initializes the provider registry on first call.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -1167,6 +1168,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
// 默认配置
|
// 默认配置
|
||||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||||||
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
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)
|
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
|
||||||
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1719,6 +1722,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||||
|
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
|
||||||
SettingKeyDefaultUserRPMLimit: "0",
|
SettingKeyDefaultUserRPMLimit: "0",
|
||||||
SettingKeyDefaultSubscriptions: "[]",
|
SettingKeyDefaultSubscriptions: "[]",
|
||||||
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
||||||
@@ -1846,6 +1850,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
} else {
|
} else {
|
||||||
result.DefaultBalance = s.cfg.Default.UserBalance
|
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])
|
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
|
||||||
|
|
||||||
// 敏感信息直接返回,方便测试连接时使用
|
// 敏感信息直接返回,方便测试连接时使用
|
||||||
@@ -2130,6 +2139,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
return result
|
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 {
|
func isFalseSettingValue(value string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "false", "0", "off", "disabled":
|
case "false", "0", "off", "disabled":
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
DefaultConcurrency int
|
DefaultConcurrency int
|
||||||
DefaultBalance float64
|
DefaultBalance float64
|
||||||
|
AffiliateRebateRate float64
|
||||||
DefaultUserRPMLimit int
|
DefaultUserRPMLimit int
|
||||||
DefaultSubscriptions []DefaultSubscriptionSetting
|
DefaultSubscriptions []DefaultSubscriptionSetting
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,53 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
|
|||||||
return svc
|
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.
|
// ProvideBillingCacheService wires BillingCacheService with its RPM dependencies.
|
||||||
func ProvideBillingCacheService(
|
func ProvideBillingCacheService(
|
||||||
cache BillingCache,
|
cache BillingCache,
|
||||||
@@ -407,7 +454,7 @@ func ProvideBillingCacheService(
|
|||||||
// ProviderSet is the Wire provider set for all services
|
// ProviderSet is the Wire provider set for all services
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
// Core services
|
// Core services
|
||||||
NewAuthService,
|
ProvideAuthService,
|
||||||
NewUserService,
|
NewUserService,
|
||||||
NewAPIKeyService,
|
NewAPIKeyService,
|
||||||
ProvideAPIKeyAuthCacheInvalidator,
|
ProvideAPIKeyAuthCacheInvalidator,
|
||||||
@@ -486,8 +533,9 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewGroupCapacityService,
|
NewGroupCapacityService,
|
||||||
NewChannelService,
|
NewChannelService,
|
||||||
NewModelPricingResolver,
|
NewModelPricingResolver,
|
||||||
|
NewAffiliateService,
|
||||||
ProvidePaymentConfigService,
|
ProvidePaymentConfigService,
|
||||||
NewPaymentService,
|
ProvidePaymentService,
|
||||||
ProvidePaymentOrderExpiryService,
|
ProvidePaymentOrderExpiryService,
|
||||||
ProvideBalanceNotifyService,
|
ProvideBalanceNotifyService,
|
||||||
ProvideChannelMonitorService,
|
ProvideChannelMonitorService,
|
||||||
|
|||||||
20
backend/migrations/130_add_user_affiliates.sql
Normal file
20
backend/migrations/130_add_user_affiliates.sql
Normal file
@@ -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 '累计返利历史金额';
|
||||||
58
backend/migrations/131_affiliate_rebate_hardening.sql
Normal file
58
backend/migrations/131_affiliate_rebate_hardening.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 1) Normalize historical affiliate rebate rate values.
|
||||||
|
-- Legacy compatibility treated 0<x<=1 as fractional inputs (e.g. 0.2 => 20%).
|
||||||
|
-- We now use pure percentage semantics, so convert persisted fractional values once.
|
||||||
|
UPDATE settings
|
||||||
|
SET value = to_char((value::numeric * 100), 'FM999999990.########'),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE key = 'affiliate_rebate_rate'
|
||||||
|
AND value ~ '^-?[0-9]+(\\.[0-9]+)?$'
|
||||||
|
AND value::numeric > 0
|
||||||
|
AND value::numeric <= 1;
|
||||||
|
|
||||||
|
-- 2) Affiliate ledger for accrual/transfer traceability.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_affiliate_ledger (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
amount DECIMAL(20,8) NOT NULL,
|
||||||
|
source_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_user_id ON user_affiliate_ledger(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_action ON user_affiliate_ledger(action);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_affiliate_ledger IS '邀请返利资金流水(累计/转入)';
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.action IS 'accrue|transfer';
|
||||||
|
|
||||||
|
-- 3) Enforce idempotency at DB layer for payment audit actions.
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY order_id, action ORDER BY id) AS rn
|
||||||
|
FROM payment_audit_logs
|
||||||
|
)
|
||||||
|
DELETE FROM payment_audit_logs p
|
||||||
|
USING ranked r
|
||||||
|
WHERE p.id = r.id
|
||||||
|
AND r.rn > 1;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_audit_logs_order_action_uniq
|
||||||
|
ON payment_audit_logs(order_id, action);
|
||||||
|
|
||||||
|
-- 4) Prevent retroactive affiliate rebate issuance for legacy completed balance orders.
|
||||||
|
INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
|
||||||
|
SELECT po.id::text,
|
||||||
|
'AFFILIATE_REBATE_SKIPPED',
|
||||||
|
'{"reason":"baseline before affiliate rebate idempotency rollout"}',
|
||||||
|
'system',
|
||||||
|
NOW()
|
||||||
|
FROM payment_orders po
|
||||||
|
WHERE po.order_type = 'balance'
|
||||||
|
AND po.status = 'COMPLETED'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM payment_audit_logs pal
|
||||||
|
WHERE pal.order_id = po.id::text
|
||||||
|
AND pal.action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
|
||||||
|
);
|
||||||
@@ -308,6 +308,7 @@ export interface SystemSettings {
|
|||||||
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
||||||
// Default settings
|
// Default settings
|
||||||
default_balance: number;
|
default_balance: number;
|
||||||
|
affiliate_rebate_rate: number;
|
||||||
default_concurrency: number;
|
default_concurrency: number;
|
||||||
default_user_rpm_limit: number;
|
default_user_rpm_limit: number;
|
||||||
default_subscriptions: DefaultSubscriptionSetting[];
|
default_subscriptions: DefaultSubscriptionSetting[];
|
||||||
@@ -489,6 +490,7 @@ export interface UpdateSettingsRequest {
|
|||||||
invitation_code_enabled?: boolean;
|
invitation_code_enabled?: boolean;
|
||||||
totp_enabled?: boolean; // TOTP 双因素认证
|
totp_enabled?: boolean; // TOTP 双因素认证
|
||||||
default_balance?: number;
|
default_balance?: number;
|
||||||
|
affiliate_rebate_rate?: number;
|
||||||
default_concurrency?: number;
|
default_concurrency?: number;
|
||||||
default_user_rpm_limit?: number;
|
default_user_rpm_limit?: number;
|
||||||
default_subscriptions?: DefaultSubscriptionSetting[];
|
default_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import {
|
|||||||
prepareOAuthBindAccessTokenCookie,
|
prepareOAuthBindAccessTokenCookie,
|
||||||
type WeChatOAuthPublicSettings,
|
type WeChatOAuthPublicSettings,
|
||||||
} from './auth'
|
} from './auth'
|
||||||
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
|
import type {
|
||||||
|
User,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
NotifyEmailEntry,
|
||||||
|
UserAuthProvider,
|
||||||
|
UserAffiliateDetail,
|
||||||
|
AffiliateTransferResponse
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user profile
|
* Get current user profile
|
||||||
@@ -168,6 +175,16 @@ export async function startOAuthBinding(
|
|||||||
window.location.href = startURL
|
window.location.href = startURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAffiliateDetail(): Promise<UserAffiliateDetail> {
|
||||||
|
const { data } = await apiClient.get<UserAffiliateDetail>('/user/aff')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferAffiliateQuota(): Promise<AffiliateTransferResponse> {
|
||||||
|
const { data } = await apiClient.post<AffiliateTransferResponse>('/user/aff/transfer')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
@@ -180,7 +197,9 @@ export const userAPI = {
|
|||||||
bindEmailIdentity,
|
bindEmailIdentity,
|
||||||
unbindAuthIdentity,
|
unbindAuthIdentity,
|
||||||
buildOAuthBindingStartURL,
|
buildOAuthBindingStartURL,
|
||||||
startOAuthBinding
|
startOAuthBinding,
|
||||||
|
getAffiliateDetail,
|
||||||
|
transferAffiliateQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
export default userAPI
|
export default userAPI
|
||||||
|
|||||||
@@ -656,6 +656,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
|||||||
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
{ 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: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||||
|
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true },
|
||||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||||
path: `/custom/${item.id}`,
|
path: `/custom/${item.id}`,
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export default {
|
|||||||
apiKeys: 'API Keys',
|
apiKeys: 'API Keys',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
redeem: 'Redeem',
|
redeem: 'Redeem',
|
||||||
|
affiliate: 'Affiliate Rebates',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
groups: 'Groups',
|
groups: 'Groups',
|
||||||
@@ -972,6 +973,47 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
affiliate: {
|
||||||
|
title: 'Affiliate Rebates',
|
||||||
|
description: 'Invite new users and convert your rebate quota into account balance',
|
||||||
|
yourCode: 'Your Affiliate Code',
|
||||||
|
inviteLink: 'Invite Link',
|
||||||
|
copyCode: 'Copy Code',
|
||||||
|
copyLink: 'Copy Link',
|
||||||
|
codeCopied: 'Affiliate code copied',
|
||||||
|
linkCopied: 'Invite link copied',
|
||||||
|
loadFailed: 'Failed to load affiliate data',
|
||||||
|
transferFailed: 'Failed to transfer affiliate quota',
|
||||||
|
stats: {
|
||||||
|
invitedUsers: 'Invited Users',
|
||||||
|
availableQuota: 'Available Rebate Quota',
|
||||||
|
totalQuota: 'Historical Rebate Quota'
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
title: 'Transfer Rebate Quota',
|
||||||
|
description: 'Move available rebate quota into your account balance',
|
||||||
|
button: 'Transfer to Balance',
|
||||||
|
transferring: 'Transferring...',
|
||||||
|
empty: 'No available rebate quota',
|
||||||
|
success: '{amount} has been transferred to your balance'
|
||||||
|
},
|
||||||
|
invitees: {
|
||||||
|
title: 'Invited Users',
|
||||||
|
empty: 'No invited users yet',
|
||||||
|
columns: {
|
||||||
|
email: 'Email',
|
||||||
|
username: 'Username',
|
||||||
|
joinedAt: 'Joined At'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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.',
|
||||||
|
line3: 'Transfer rebate quota to balance at any time.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Redeem
|
// Redeem
|
||||||
redeem: {
|
redeem: {
|
||||||
title: 'Redeem Code',
|
title: 'Redeem Code',
|
||||||
@@ -4837,6 +4879,9 @@ export default {
|
|||||||
description: 'Default values for new users',
|
description: 'Default values for new users',
|
||||||
defaultBalance: 'Default Balance',
|
defaultBalance: 'Default Balance',
|
||||||
defaultBalanceHint: 'Initial balance for new users',
|
defaultBalanceHint: 'Initial balance for new users',
|
||||||
|
affiliateRebateRate: 'Affiliate Rebate Rate',
|
||||||
|
affiliateRebateRateHint:
|
||||||
|
'Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)',
|
||||||
defaultConcurrency: 'Default Concurrency',
|
defaultConcurrency: 'Default Concurrency',
|
||||||
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
|
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
|
||||||
defaultUserRpmLimit: 'Default User RPM Limit',
|
defaultUserRpmLimit: 'Default User RPM Limit',
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export default {
|
|||||||
apiKeys: 'API 密钥',
|
apiKeys: 'API 密钥',
|
||||||
usage: '使用记录',
|
usage: '使用记录',
|
||||||
redeem: '兑换',
|
redeem: '兑换',
|
||||||
|
affiliate: '邀请返利',
|
||||||
profile: '个人资料',
|
profile: '个人资料',
|
||||||
users: '用户管理',
|
users: '用户管理',
|
||||||
groups: '分组管理',
|
groups: '分组管理',
|
||||||
@@ -976,6 +977,47 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
affiliate: {
|
||||||
|
title: '邀请返利',
|
||||||
|
description: '邀请新用户注册,并将返利额度转入账户余额',
|
||||||
|
yourCode: '我的邀请码',
|
||||||
|
inviteLink: '邀请链接',
|
||||||
|
copyCode: '复制邀请码',
|
||||||
|
copyLink: '复制链接',
|
||||||
|
codeCopied: '邀请码已复制',
|
||||||
|
linkCopied: '邀请链接已复制',
|
||||||
|
loadFailed: '加载邀请返利数据失败',
|
||||||
|
transferFailed: '转入余额失败',
|
||||||
|
stats: {
|
||||||
|
invitedUsers: '邀请人数',
|
||||||
|
availableQuota: '可转返利额度',
|
||||||
|
totalQuota: '历史返利额度'
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
title: '返利额度转余额',
|
||||||
|
description: '将当前可用返利额度一键转入账户余额',
|
||||||
|
button: '转入余额',
|
||||||
|
transferring: '转入中...',
|
||||||
|
empty: '当前没有可转入额度',
|
||||||
|
success: '已转入余额:{amount}'
|
||||||
|
},
|
||||||
|
invitees: {
|
||||||
|
title: '已邀请用户',
|
||||||
|
empty: '暂无邀请记录',
|
||||||
|
columns: {
|
||||||
|
email: '邮箱',
|
||||||
|
username: '用户名',
|
||||||
|
joinedAt: '注册时间'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tips: {
|
||||||
|
title: '使用说明',
|
||||||
|
line1: '将邀请码或邀请链接分享给新用户。',
|
||||||
|
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
|
||||||
|
line3: '返利额度可随时转入账户余额。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Redeem
|
// Redeem
|
||||||
redeem: {
|
redeem: {
|
||||||
title: '兑换码',
|
title: '兑换码',
|
||||||
@@ -5000,6 +5042,8 @@ export default {
|
|||||||
description: '新用户的默认值',
|
description: '新用户的默认值',
|
||||||
defaultBalance: '默认余额',
|
defaultBalance: '默认余额',
|
||||||
defaultBalanceHint: '新用户的初始余额',
|
defaultBalanceHint: '新用户的初始余额',
|
||||||
|
affiliateRebateRate: '邀请返利比例',
|
||||||
|
affiliateRebateRateHint: '充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)',
|
||||||
defaultConcurrency: '默认并发数',
|
defaultConcurrency: '默认并发数',
|
||||||
defaultConcurrencyHint: '新用户的最大并发请求数',
|
defaultConcurrencyHint: '新用户的最大并发请求数',
|
||||||
defaultUserRpmLimit: '默认用户 RPM 限制',
|
defaultUserRpmLimit: '默认用户 RPM 限制',
|
||||||
|
|||||||
@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
descriptionKey: 'redeem.description'
|
descriptionKey: 'redeem.description'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/affiliate',
|
||||||
|
name: 'Affiliate',
|
||||||
|
component: () => import('@/views/user/AffiliateView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: false,
|
||||||
|
title: 'Affiliate',
|
||||||
|
titleKey: 'affiliate.title',
|
||||||
|
descriptionKey: 'affiliate.description'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/available-channels',
|
path: '/available-channels',
|
||||||
name: 'UserAvailableChannels',
|
name: 'UserAvailableChannels',
|
||||||
|
|||||||
@@ -122,6 +122,29 @@ export interface RegisterRequest {
|
|||||||
turnstile_token?: string
|
turnstile_token?: string
|
||||||
promo_code?: string
|
promo_code?: string
|
||||||
invitation_code?: string
|
invitation_code?: string
|
||||||
|
aff_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffiliateInvitee {
|
||||||
|
user_id: number
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAffiliateDetail {
|
||||||
|
user_id: number
|
||||||
|
aff_code: string
|
||||||
|
inviter_id?: number | null
|
||||||
|
aff_count: number
|
||||||
|
aff_quota: number
|
||||||
|
aff_history_quota: number
|
||||||
|
invitees: AffiliateInvitee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffiliateTransferResponse {
|
||||||
|
transferred_quota: number
|
||||||
|
balance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendVerifyCodeRequest {
|
export interface SendVerifyCodeRequest {
|
||||||
|
|||||||
@@ -2153,6 +2153,31 @@
|
|||||||
{{ t("admin.settings.defaults.defaultBalanceHint") }}
|
{{ t("admin.settings.defaults.defaultBalanceHint") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ t("admin.settings.defaults.affiliateRebateRate") }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model.number="form.affiliate_rebate_rate"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="input pr-8"
|
||||||
|
placeholder="20"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
>%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.settings.defaults.affiliateRebateRateHint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
@@ -4972,6 +4997,7 @@ const form = reactive<SettingsForm>({
|
|||||||
totp_enabled: false,
|
totp_enabled: false,
|
||||||
totp_encryption_key_configured: false,
|
totp_encryption_key_configured: false,
|
||||||
default_balance: 0,
|
default_balance: 0,
|
||||||
|
affiliate_rebate_rate: 20,
|
||||||
default_concurrency: 1,
|
default_concurrency: 1,
|
||||||
default_subscriptions: [],
|
default_subscriptions: [],
|
||||||
force_email_on_third_party_signup: false,
|
force_email_on_third_party_signup: false,
|
||||||
@@ -5894,6 +5920,10 @@ async function saveSettings() {
|
|||||||
password_reset_enabled: form.password_reset_enabled,
|
password_reset_enabled: form.password_reset_enabled,
|
||||||
totp_enabled: form.totp_enabled,
|
totp_enabled: form.totp_enabled,
|
||||||
default_balance: form.default_balance,
|
default_balance: form.default_balance,
|
||||||
|
affiliate_rebate_rate: Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, Number(form.affiliate_rebate_rate) || 0),
|
||||||
|
),
|
||||||
default_concurrency: form.default_concurrency,
|
default_concurrency: form.default_concurrency,
|
||||||
default_subscriptions: normalizedDefaultSubscriptions,
|
default_subscriptions: normalizedDefaultSubscriptions,
|
||||||
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
|
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ const password = ref<string>('')
|
|||||||
const initialTurnstileToken = ref<string>('')
|
const initialTurnstileToken = ref<string>('')
|
||||||
const promoCode = ref<string>('')
|
const promoCode = ref<string>('')
|
||||||
const invitationCode = ref<string>('')
|
const invitationCode = ref<string>('')
|
||||||
|
const affCode = ref<string>('')
|
||||||
const pendingAuthToken = ref<string>('')
|
const pendingAuthToken = ref<string>('')
|
||||||
const pendingAuthTokenField = ref<PendingAuthTokenField>('pending_auth_token')
|
const pendingAuthTokenField = ref<PendingAuthTokenField>('pending_auth_token')
|
||||||
const pendingProvider = ref<string>('')
|
const pendingProvider = ref<string>('')
|
||||||
@@ -260,6 +261,7 @@ onMounted(async () => {
|
|||||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||||
promoCode.value = registerData.promo_code || ''
|
promoCode.value = registerData.promo_code || ''
|
||||||
invitationCode.value = registerData.invitation_code || ''
|
invitationCode.value = registerData.invitation_code || ''
|
||||||
|
affCode.value = registerData.aff_code || ''
|
||||||
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
|
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
|
||||||
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
|
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
|
||||||
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
|
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
|
||||||
@@ -524,7 +526,8 @@ async function handleVerify(): Promise<void> {
|
|||||||
verify_code: verifyCode.value.trim(),
|
verify_code: verifyCode.value.trim(),
|
||||||
turnstile_token: initialTurnstileToken.value || undefined,
|
turnstile_token: initialTurnstileToken.value || undefined,
|
||||||
promo_code: promoCode.value || undefined,
|
promo_code: promoCode.value || undefined,
|
||||||
invitation_code: invitationCode.value || undefined
|
invitation_code: invitationCode.value || undefined,
|
||||||
|
...(affCode.value ? { aff_code: affCode.value } : {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -351,7 +351,8 @@ const formData = reactive({
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
promo_code: '',
|
promo_code: '',
|
||||||
invitation_code: ''
|
invitation_code: '',
|
||||||
|
aff_code: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
@@ -406,6 +407,10 @@ onMounted(async () => {
|
|||||||
await validatePromoCodeDebounced(promoParam)
|
await validatePromoCodeDebounced(promoParam)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const affParam = (route.query.aff as string) || (route.query.aff_code as string)
|
||||||
|
if (affParam) {
|
||||||
|
formData.aff_code = affParam.trim()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', error)
|
console.error('Failed to load public settings:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -707,7 +712,8 @@ async function handleRegister(): Promise<void> {
|
|||||||
password: formData.password,
|
password: formData.password,
|
||||||
turnstile_token: turnstileToken.value,
|
turnstile_token: turnstileToken.value,
|
||||||
promo_code: formData.promo_code || undefined,
|
promo_code: formData.promo_code || undefined,
|
||||||
invitation_code: formData.invitation_code || undefined
|
invitation_code: formData.invitation_code || undefined,
|
||||||
|
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -722,7 +728,8 @@ async function handleRegister(): Promise<void> {
|
|||||||
password: formData.password,
|
password: formData.password,
|
||||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||||
promo_code: formData.promo_code || undefined,
|
promo_code: formData.promo_code || undefined,
|
||||||
invitation_code: formData.invitation_code || undefined
|
invitation_code: formData.invitation_code || undefined,
|
||||||
|
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
|
|||||||
201
frontend/src/views/user/AffiliateView.vue
Normal file
201
frontend/src/views/user/AffiliateView.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="card p-5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ formatCount(detail.aff_count) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.availableQuota') }}</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{{ formatCurrency(detail.aff_quota) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.totalQuota') }}</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ formatCurrency(detail.aff_history_quota) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.description') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.yourCode') }}</p>
|
||||||
|
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<code class="flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ detail.aff_code }}</code>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="copyCode">
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
<span>{{ t('affiliate.copyCode') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.inviteLink') }}</p>
|
||||||
|
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<code class="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">{{ inviteLink }}</code>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="copyInviteLink">
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
<span>{{ t('affiliate.copyLink') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-xl border border-primary-200 bg-primary-50 p-4 dark:border-primary-900/40 dark:bg-primary-900/20">
|
||||||
|
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
|
||||||
|
<li>1. {{ t('affiliate.tips.line1') }}</li>
|
||||||
|
<li>2. {{ t('affiliate.tips.line2') }}</li>
|
||||||
|
<li>3. {{ t('affiliate.tips.line3') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.transfer.title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.transfer.description') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="transferring || detail.aff_quota <= 0"
|
||||||
|
@click="transferQuota"
|
||||||
|
>
|
||||||
|
<Icon v-if="transferring" name="refresh" size="sm" class="animate-spin" />
|
||||||
|
<Icon v-else name="dollar" size="sm" />
|
||||||
|
<span>{{ transferring ? t('affiliate.transfer.transferring') : t('affiliate.transfer.button') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.aff_quota <= 0" class="mt-3 text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{{ t('affiliate.transfer.empty') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.invitees.title') }}</h3>
|
||||||
|
<div v-if="detail.invitees.length === 0" class="mt-4 rounded-xl border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||||
|
{{ t('affiliate.invitees.empty') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-4 overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[560px] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||||
|
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.email') }}</th>
|
||||||
|
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.username') }}</th>
|
||||||
|
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.joinedAt') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in detail.invitees"
|
||||||
|
:key="item.user_id"
|
||||||
|
class="border-b border-gray-100 last:border-b-0 dark:border-dark-800"
|
||||||
|
>
|
||||||
|
<td class="px-3 py-3 text-gray-900 dark:text-white">{{ item.email || '-' }}</td>
|
||||||
|
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ item.username || '-' }}</td>
|
||||||
|
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ formatDateTime(item.created_at) || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import userAPI from '@/api/user'
|
||||||
|
import type { UserAffiliateDetail } from '@/types'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||||
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const transferring = ref(false)
|
||||||
|
const detail = ref<UserAffiliateDetail | null>(null)
|
||||||
|
|
||||||
|
const inviteLink = computed(() => {
|
||||||
|
if (!detail.value) return ''
|
||||||
|
if (typeof window === 'undefined') return `/register?aff=${encodeURIComponent(detail.value.aff_code)}`
|
||||||
|
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatCount(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAffiliateDetail(silent = false): Promise<void> {
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
detail.value = await userAPI.getAffiliateDetail()
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(extractApiErrorMessage(error, t('affiliate.loadFailed')))
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode(): Promise<void> {
|
||||||
|
if (!detail.value?.aff_code) return
|
||||||
|
await copyToClipboard(detail.value.aff_code, t('affiliate.codeCopied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInviteLink(): Promise<void> {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
await copyToClipboard(inviteLink.value, t('affiliate.linkCopied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transferQuota(): Promise<void> {
|
||||||
|
if (!detail.value || detail.value.aff_quota <= 0 || transferring.value) return
|
||||||
|
transferring.value = true
|
||||||
|
try {
|
||||||
|
const resp = await userAPI.transferAffiliateQuota()
|
||||||
|
appStore.showSuccess(t('affiliate.transfer.success', { amount: formatCurrency(resp.transferred_quota) }))
|
||||||
|
await Promise.all([
|
||||||
|
loadAffiliateDetail(true),
|
||||||
|
authStore.refreshUser().catch(() => undefined),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(extractApiErrorMessage(error, t('affiliate.transferFailed')))
|
||||||
|
} finally {
|
||||||
|
transferring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadAffiliateDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user