diff --git a/backend/cmd/jwtgen/main.go b/backend/cmd/jwtgen/main.go index c461198b..139a3a39 100644 --- a/backend/cmd/jwtgen/main.go +++ b/backend/cmd/jwtgen/main.go @@ -33,7 +33,7 @@ func main() { }() userRepo := repository.NewUserRepository(client, sqlDB) - authService := service.NewAuthService(userRepo, cfg, nil, nil, nil, nil, nil) + authService := service.NewAuthService(userRepo, nil, cfg, nil, nil, nil, nil, nil) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 9ccbddc2..694d05a7 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -43,6 +43,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } userRepository := repository.NewUserRepository(client, db) + redeemCodeRepository := repository.NewRedeemCodeRepository(client) settingRepository := repository.NewSettingRepository(client) settingService := service.NewSettingService(settingRepository, configConfig) redisClient := repository.ProvideRedis(configConfig) @@ -61,24 +62,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) - authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) + authService := service.NewAuthService(userRepository, redeemCodeRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator) + subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) + redeemCache := repository.NewRedeemCache(redisClient) + redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) secretEncryptor, err := repository.NewAESEncryptor(configConfig) if err != nil { return nil, err } totpCache := repository.NewTotpCache(redisClient) totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService) - authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, totpService) + authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService) userHandler := handler.NewUserHandler(userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - redeemCodeRepository := repository.NewRedeemCodeRepository(client) - subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) - redeemCache := repository.NewRedeemCache(redisClient) - redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) redeemHandler := handler.NewRedeemHandler(redeemService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) announcementRepository := repository.NewAnnouncementRepository(client) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 4ecea9d8..3655e07f 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -36,6 +36,7 @@ const ( RedeemTypeBalance = "balance" RedeemTypeConcurrency = "concurrency" RedeemTypeSubscription = "subscription" + RedeemTypeInvitation = "invitation" ) // PromoCode status constants diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index f1b68334..e229385f 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -29,7 +29,7 @@ func NewRedeemHandler(adminService service.AdminService) *RedeemHandler { // GenerateRedeemCodesRequest represents generate redeem codes request type GenerateRedeemCodesRequest struct { Count int `json:"count" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=balance concurrency subscription"` + Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` Value float64 `json:"value" binding:"min=0"` GroupID *int64 `json:"group_id"` // 订阅类型必填 ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index cdad3659..1e723ee5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -49,6 +49,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SMTPHost: settings.SMTPHost, @@ -94,11 +95,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -291,6 +293,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EmailVerifyEnabled: req.EmailVerifyEnabled, PromoCodeEnabled: req.PromoCodeEnabled, PasswordResetEnabled: req.PasswordResetEnabled, + InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, @@ -370,6 +373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled, + InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SMTPHost: updatedSettings.SMTPHost, diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3522407d..75ea9f08 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -15,23 +15,25 @@ import ( // AuthHandler handles authentication-related requests type AuthHandler struct { - cfg *config.Config - authService *service.AuthService - userService *service.UserService - settingSvc *service.SettingService - promoService *service.PromoService - totpService *service.TotpService + cfg *config.Config + authService *service.AuthService + userService *service.UserService + settingSvc *service.SettingService + promoService *service.PromoService + redeemService *service.RedeemService + totpService *service.TotpService } // NewAuthHandler creates a new AuthHandler -func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, totpService *service.TotpService) *AuthHandler { +func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService) *AuthHandler { return &AuthHandler{ - cfg: cfg, - authService: authService, - userService: userService, - settingSvc: settingService, - promoService: promoService, - totpService: totpService, + cfg: cfg, + authService: authService, + userService: userService, + settingSvc: settingService, + promoService: promoService, + redeemService: redeemService, + totpService: totpService, } } @@ -41,7 +43,8 @@ type RegisterRequest struct { Password string `json:"password" binding:"required,min=6"` VerifyCode string `json:"verify_code"` TurnstileToken string `json:"turnstile_token"` - PromoCode string `json:"promo_code"` // 注册优惠码 + PromoCode string `json:"promo_code"` // 注册优惠码 + InvitationCode string `json:"invitation_code"` // 邀请码 } // SendVerifyCodeRequest 发送验证码请求 @@ -87,7 +90,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } } - token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode) + token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode, req.InvitationCode) if err != nil { response.ErrorFrom(c, err) return @@ -346,6 +349,67 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) { }) } +// ValidateInvitationCodeRequest 验证邀请码请求 +type ValidateInvitationCodeRequest struct { + Code string `json:"code" binding:"required"` +} + +// ValidateInvitationCodeResponse 验证邀请码响应 +type ValidateInvitationCodeResponse struct { + Valid bool `json:"valid"` + ErrorCode string `json:"error_code,omitempty"` +} + +// ValidateInvitationCode 验证邀请码(公开接口,注册前调用) +// POST /api/v1/auth/validate-invitation-code +func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) { + // 检查邀请码功能是否启用 + if h.settingSvc == nil || !h.settingSvc.IsInvitationCodeEnabled(c.Request.Context()) { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_DISABLED", + }) + return + } + + var req ValidateInvitationCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // 验证邀请码 + redeemCode, err := h.redeemService.GetByCode(c.Request.Context(), req.Code) + if err != nil { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_NOT_FOUND", + }) + return + } + + // 检查类型和状态 + if redeemCode.Type != service.RedeemTypeInvitation { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_INVALID", + }) + return + } + + if redeemCode.Status != service.StatusUnused { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_USED", + }) + return + } + + response.Success(c, ValidateInvitationCodeResponse{ + Valid: true, + }) +} + // ForgotPasswordRequest 忘记密码请求 type ForgotPasswordRequest struct { Email string `json:"email" binding:"required,email"` diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 152da756..be94bc16 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -6,6 +6,7 @@ type SystemSettings struct { EmailVerifyEnabled bool `json:"email_verify_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 @@ -63,6 +64,7 @@ type PublicSettings struct { EmailVerifyEnabled bool `json:"email_verify_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 9fd27dc3..2029f116 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -36,6 +36,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 6adab853..71576c5b 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -600,7 +600,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingService := service.NewSettingService(settingRepo, cfg) adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil) - authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, nil) + authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 33a88e82..24f6d549 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -32,6 +32,10 @@ func RegisterAuthRoutes( auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, }), h.Auth.ValidatePromoCode) + // 邀请码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close) + auth.POST("/validate-invitation-code", rateLimiter.LimitWithOptions("validate-invitation", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ValidateInvitationCode) // 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close) auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index f51fae24..c824ec1e 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -19,17 +19,19 @@ import ( ) var ( - ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") - ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") - ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") - ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") - ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") - ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") - ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") - ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") - ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") - ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") - ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") + ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") + ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") + ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") + ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") + ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") + ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") + ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") + ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") + ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") + ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") + ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") + ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") + ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") ) // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 @@ -47,6 +49,7 @@ type JWTClaims struct { // AuthService 认证服务 type AuthService struct { userRepo UserRepository + redeemRepo RedeemCodeRepository cfg *config.Config settingService *SettingService emailService *EmailService @@ -58,6 +61,7 @@ type AuthService struct { // NewAuthService 创建认证服务实例 func NewAuthService( userRepo UserRepository, + redeemRepo RedeemCodeRepository, cfg *config.Config, settingService *SettingService, emailService *EmailService, @@ -67,6 +71,7 @@ func NewAuthService( ) *AuthService { return &AuthService{ userRepo: userRepo, + redeemRepo: redeemRepo, cfg: cfg, settingService: settingService, emailService: emailService, @@ -78,11 +83,11 @@ func NewAuthService( // Register 用户注册,返回token和用户 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和用户 -func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode string) (string, *User, error) { +// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户 +func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) { // 检查是否开放注册(默认关闭:settingService 未配置时不允许注册) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled @@ -93,6 +98,26 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrEmailReserved } + // 检查是否需要邀请码 + var invitationRedeemCode *RedeemCode + if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) { + if invitationCode == "" { + return "", nil, ErrInvitationCodeRequired + } + // 验证邀请码 + redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode) + if err != nil { + log.Printf("[Auth] Invalid invitation code: %s, error: %v", invitationCode, err) + return "", nil, ErrInvitationCodeInvalid + } + // 检查类型和状态 + if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { + log.Printf("[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status) + return "", nil, ErrInvitationCodeInvalid + } + invitationRedeemCode = redeemCode + } + // 检查是否需要邮件验证 if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) { // 如果邮件验证已开启但邮件服务未配置,拒绝注册 @@ -153,6 +178,14 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrServiceUnavailable } + // 标记邀请码为已使用(如果使用了邀请码) + if invitationRedeemCode != nil { + if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { + // 邀请码标记失败不影响注册,只记录日志 + log.Printf("[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err) + } + } + // 应用优惠码(如果提供且功能已启用) if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) { if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index e31ca561..aa3c769e 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -115,6 +115,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E return NewAuthService( repo, + nil, // redeemRepo cfg, settingService, emailService, @@ -152,7 +153,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi }, nil) // 应返回服务不可用错误,而不是允许绕过验证 - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "") require.ErrorIs(t, err, ErrServiceUnavailable) } @@ -164,7 +165,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "") require.ErrorIs(t, err, ErrEmailVerifyRequired) } @@ -178,7 +179,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "") require.ErrorIs(t, err, ErrInvalidVerifyCode) require.ErrorContains(t, err, "verify code") } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index eee8bddd..2db72825 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -38,6 +38,7 @@ const ( RedeemTypeBalance = domain.RedeemTypeBalance RedeemTypeConcurrency = domain.RedeemTypeConcurrency RedeemTypeSubscription = domain.RedeemTypeSubscription + RedeemTypeInvitation = domain.RedeemTypeInvitation ) // PromoCode status constants @@ -71,10 +72,11 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" // Setting keys const ( // 注册设置 - SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 - SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 - SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 + SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index ff52dc47..adcafb3f 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return nil, errors.New("count must be greater than 0") } - if req.Value <= 0 { + // 邀请码类型不需要数值,其他类型需要 + if req.Type != RedeemTypeInvitation && req.Value <= 0 { return nil, errors.New("value must be greater than 0") } @@ -139,6 +140,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ codeType = RedeemTypeBalance } + // 邀请码类型的 value 设为 0 + value := req.Value + if codeType == RedeemTypeInvitation { + value = 0 + } + codes := make([]RedeemCode, 0, req.Count) for i := 0; i < req.Count; i++ { code, err := s.GenerateRandomCode() @@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ codes = append(codes, RedeemCode{ Code: code, Type: codeType, - Value: req.Value, + Value: value, Status: StatusUnused, }) } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 60ae9543..f5ba9d71 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyEmailVerifyEnabled, SettingKeyPromoCodeEnabled, SettingKeyPasswordResetEnabled, + SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, @@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], @@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any EmailVerifyEnabled bool `json:"email_verify_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` @@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, @@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) + updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) // 邮件服务设置(只有非空才更新密码) @@ -286,6 +291,15 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { return value != "false" } +// IsInvitationCodeEnabled 检查是否启用邀请码注册功能 +func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + // IsPasswordResetEnabled 检查是否启用密码重置功能 // 要求:必须同时开启邮件验证 func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { @@ -401,6 +415,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 358911dc..0c7bab67 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -1,11 +1,12 @@ package service type SystemSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool - PasswordResetEnabled bool - TotpEnabled bool // TOTP 双因素认证 + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 SMTPHost string SMTPPort int @@ -61,21 +62,22 @@ type SystemSettings struct { } type PublicSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool - PasswordResetEnabled bool - TotpEnabled bool // TOTP 双因素认证 - TurnstileEnabled bool - TurnstileSiteKey string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - HideCcsImportButton bool + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + TurnstileEnabled bool + TurnstileSiteKey string + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + HideCcsImportButton bool PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index a0595e4f..3dc76fe7 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -14,6 +14,7 @@ export interface SystemSettings { email_verify_enabled: boolean promo_code_enabled: boolean password_reset_enabled: boolean + invitation_code_enabled: boolean totp_enabled: boolean // TOTP 双因素认证 totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 // Default settings @@ -72,6 +73,7 @@ export interface UpdateSettingsRequest { email_verify_enabled?: boolean promo_code_enabled?: boolean password_reset_enabled?: boolean + invitation_code_enabled?: boolean totp_enabled?: boolean // TOTP 双因素认证 default_balance?: number default_concurrency?: number diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index bbd5ed74..40c9c5a4 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise { + const { data } = await apiClient.post('/auth/validate-invitation-code', { code }) + return data +} + /** * Forgot password request */ @@ -229,6 +247,7 @@ export const authAPI = { getPublicSettings, sendVerifyCode, validatePromoCode, + validateInvitationCode, forgotPassword, resetPassword } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 7c4df36b..0bce787c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -265,6 +265,13 @@ export default { promoCodeAlreadyUsed: 'You have already used this promo code', promoCodeValidating: 'Promo code is being validated, please wait', promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', + invitationCodeLabel: 'Invitation Code', + invitationCodePlaceholder: 'Enter invitation code', + invitationCodeRequired: 'Invitation code is required', + invitationCodeValid: 'Invitation code is valid', + invitationCodeInvalid: 'Invalid or used invitation code', + invitationCodeValidating: 'Validating invitation code...', + invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', linuxdo: { signIn: 'Continue with Linux.do', orContinue: 'or continue with email', @@ -1899,6 +1906,8 @@ export default { balance: 'Balance', concurrency: 'Concurrency', subscription: 'Subscription', + invitation: 'Invitation', + invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.', unused: 'Unused', used: 'Used', columns: { @@ -1945,6 +1954,7 @@ export default { balance: 'Balance', concurrency: 'Concurrency', subscription: 'Subscription', + invitation: 'Invitation', // Admin adjustment types (created when admin modifies user balance/concurrency) admin_balance: 'Balance (Admin)', admin_concurrency: 'Concurrency (Admin)' @@ -2896,6 +2906,8 @@ export default { emailVerificationHint: 'Require email verification for new registrations', promoCode: 'Promo Code', promoCodeHint: 'Allow users to use promo codes during registration', + invitationCode: 'Invitation Code Registration', + invitationCodeHint: 'When enabled, users must enter a valid invitation code to register', passwordReset: 'Password Reset', passwordResetHint: 'Allow users to reset their password via email', totp: 'Two-Factor Authentication (2FA)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ba1c775f..b8334484 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -262,6 +262,13 @@ export default { promoCodeAlreadyUsed: '您已使用过此优惠码', promoCodeValidating: '优惠码正在验证中,请稍候', promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', + invitationCodeLabel: '邀请码', + invitationCodePlaceholder: '请输入邀请码', + invitationCodeRequired: '请输入邀请码', + invitationCodeValid: '邀请码有效', + invitationCodeInvalid: '邀请码无效或已被使用', + invitationCodeValidating: '正在验证邀请码...', + invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', linuxdo: { signIn: '使用 Linux.do 登录', orContinue: '或使用邮箱密码继续', @@ -2022,6 +2029,7 @@ export default { balance: '余额', concurrency: '并发数', subscription: '订阅', + invitation: '邀请码', // 管理员在用户管理页面调整余额/并发时产生的记录 admin_balance: '余额(管理员)', admin_concurrency: '并发数(管理员)' @@ -2030,6 +2038,8 @@ export default { balance: '余额', concurrency: '并发数', subscription: '订阅', + invitation: '邀请码', + invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。', allTypes: '全部类型', allStatus: '全部状态', unused: '未使用', @@ -3049,6 +3059,8 @@ export default { emailVerificationHint: '新用户注册时需要验证邮箱', promoCode: '优惠码', promoCodeHint: '允许用户在注册时使用优惠码', + invitationCode: '邀请码注册', + invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码', passwordReset: '忘记密码', passwordResetHint: '允许用户通过邮箱重置密码', totp: '双因素认证 (2FA)', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 2d66159c..0abf5a53 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => { email_verify_enabled: false, promo_code_enabled: true, password_reset_enabled: false, + invitation_code_enabled: false, turnstile_enabled: false, turnstile_site_key: '', site_name: siteName.value, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7c6cbf52..1a55d9ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -55,6 +55,7 @@ export interface RegisterRequest { verify_code?: string turnstile_token?: string promo_code?: string + invitation_code?: string } export interface SendVerifyCodeRequest { @@ -72,6 +73,7 @@ export interface PublicSettings { email_verify_enabled: boolean promo_code_enabled: boolean password_reset_enabled: boolean + invitation_code_enabled: boolean turnstile_enabled: boolean turnstile_site_key: string site_name: string @@ -701,7 +703,7 @@ export interface UpdateProxyRequest { // ==================== Usage & Redeem Types ==================== -export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' +export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' export interface UsageLog { id: number diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue index 907c7541..06794879 100644 --- a/frontend/src/views/admin/RedeemView.vue +++ b/frontend/src/views/admin/RedeemView.vue @@ -213,7 +213,7 @@