Merge pull request #420 from shuike/feat-invitation-code

feat: 增加邀请码注册功能
This commit is contained in:
Wesley Liddick
2026-02-03 14:44:15 +08:00
committed by GitHub
26 changed files with 476 additions and 83 deletions

View File

@@ -33,7 +33,7 @@ func main() {
}() }()
userRepo := repository.NewUserRepository(client, sqlDB) 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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()

View File

@@ -43,6 +43,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err return nil, err
} }
userRepository := repository.NewUserRepository(client, db) userRepository := repository.NewUserRepository(client, db)
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
settingRepository := repository.NewSettingRepository(client) settingRepository := repository.NewSettingRepository(client)
settingService := service.NewSettingService(settingRepository, configConfig) settingService := service.NewSettingService(settingRepository, configConfig)
redisClient := repository.ProvideRedis(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) apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) 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) 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) secretEncryptor, err := repository.NewAESEncryptor(configConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
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, totpService) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
userHandler := handler.NewUserHandler(userService) userHandler := handler.NewUserHandler(userService)
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)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) 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) redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
announcementRepository := repository.NewAnnouncementRepository(client) announcementRepository := repository.NewAnnouncementRepository(client)

View File

@@ -36,6 +36,7 @@ const (
RedeemTypeBalance = "balance" RedeemTypeBalance = "balance"
RedeemTypeConcurrency = "concurrency" RedeemTypeConcurrency = "concurrency"
RedeemTypeSubscription = "subscription" RedeemTypeSubscription = "subscription"
RedeemTypeInvitation = "invitation"
) )
// PromoCode status constants // PromoCode status constants

View File

@@ -29,7 +29,7 @@ func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
// GenerateRedeemCodesRequest represents generate redeem codes request // GenerateRedeemCodesRequest represents generate redeem codes request
type GenerateRedeemCodesRequest struct { type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"` 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"` Value float64 `json:"value" binding:"min=0"`
GroupID *int64 `json:"group_id"` // 订阅类型必填 GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用默认30天最大100年 ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用默认30天最大100年

View File

@@ -49,6 +49,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled, TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: settings.SMTPHost, SMTPHost: settings.SMTPHost,
@@ -94,11 +95,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求 // UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct { type UpdateSettingsRequest struct {
// 注册设置 // 注册设置
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
// 邮件服务设置 // 邮件服务设置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
@@ -291,6 +293,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,
PromoCodeEnabled: req.PromoCodeEnabled, PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled, PasswordResetEnabled: req.PasswordResetEnabled,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled, TotpEnabled: req.TotpEnabled,
SMTPHost: req.SMTPHost, SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort, SMTPPort: req.SMTPPort,
@@ -370,6 +373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled, TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: updatedSettings.SMTPHost, SMTPHost: updatedSettings.SMTPHost,

View File

@@ -15,23 +15,25 @@ import (
// AuthHandler handles authentication-related requests // AuthHandler handles authentication-related requests
type AuthHandler struct { type AuthHandler struct {
cfg *config.Config cfg *config.Config
authService *service.AuthService authService *service.AuthService
userService *service.UserService userService *service.UserService
settingSvc *service.SettingService settingSvc *service.SettingService
promoService *service.PromoService promoService *service.PromoService
totpService *service.TotpService redeemService *service.RedeemService
totpService *service.TotpService
} }
// NewAuthHandler creates a new AuthHandler // 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{ return &AuthHandler{
cfg: cfg, cfg: cfg,
authService: authService, authService: authService,
userService: userService, userService: userService,
settingSvc: settingService, settingSvc: settingService,
promoService: promoService, promoService: promoService,
totpService: totpService, redeemService: redeemService,
totpService: totpService,
} }
} }
@@ -41,7 +43,8 @@ type RegisterRequest struct {
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`
VerifyCode string `json:"verify_code"` VerifyCode string `json:"verify_code"`
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"` // 邀请码
} }
// SendVerifyCodeRequest 发送验证码请求 // 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 { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return 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 忘记密码请求 // ForgotPasswordRequest 忘记密码请求
type ForgotPasswordRequest struct { type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`

View File

@@ -6,6 +6,7 @@ type SystemSettings struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
@@ -63,6 +64,7 @@ type PublicSettings struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSiteKey string `json:"turnstile_site_key"`

View File

@@ -36,6 +36,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSiteKey: settings.TurnstileSiteKey,

View File

@@ -488,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
"fallback_model_openai": "gpt-4o", "fallback_model_openai": "gpt-4o",
"enable_identity_patch": true, "enable_identity_patch": true,
"identity_patch_prompt": "", "identity_patch_prompt": "",
"invitation_code_enabled": false,
"home_content": "", "home_content": "",
"hide_ccs_import_button": false, "hide_ccs_import_button": false,
"purchase_subscription_enabled": false, "purchase_subscription_enabled": false,
@@ -600,7 +601,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingService := service.NewSettingService(settingRepo, cfg) settingService := service.NewSettingService(settingRepo, cfg)
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil) 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) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil)

View File

@@ -32,6 +32,10 @@ func RegisterAuthRoutes(
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{ auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose, FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ValidatePromoCode) }), 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 // 忘记密码接口添加速率限制:每分钟最多 5 次Redis 故障时 fail-close
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{ auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose, FailureMode: middleware.RateLimitFailClose,

View File

@@ -19,17 +19,19 @@ import (
) )
var ( var (
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") 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 触发解析时的异常内存分配。 // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
@@ -47,6 +49,7 @@ type JWTClaims struct {
// AuthService 认证服务 // AuthService 认证服务
type AuthService struct { type AuthService struct {
userRepo UserRepository userRepo UserRepository
redeemRepo RedeemCodeRepository
cfg *config.Config cfg *config.Config
settingService *SettingService settingService *SettingService
emailService *EmailService emailService *EmailService
@@ -58,6 +61,7 @@ type AuthService struct {
// NewAuthService 创建认证服务实例 // NewAuthService 创建认证服务实例
func NewAuthService( func NewAuthService(
userRepo UserRepository, userRepo UserRepository,
redeemRepo RedeemCodeRepository,
cfg *config.Config, cfg *config.Config,
settingService *SettingService, settingService *SettingService,
emailService *EmailService, emailService *EmailService,
@@ -67,6 +71,7 @@ func NewAuthService(
) *AuthService { ) *AuthService {
return &AuthService{ return &AuthService{
userRepo: userRepo, userRepo: userRepo,
redeemRepo: redeemRepo,
cfg: cfg, cfg: cfg,
settingService: settingService, settingService: settingService,
emailService: emailService, emailService: emailService,
@@ -78,11 +83,11 @@ func NewAuthService(
// 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 string) (string, *User, error) { func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) {
// 检查是否开放注册默认关闭settingService 未配置时不允许注册) // 检查是否开放注册默认关闭settingService 未配置时不允许注册)
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return "", nil, ErrRegDisabled return "", nil, ErrRegDisabled
@@ -93,6 +98,26 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrEmailReserved 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) { if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
// 如果邮件验证已开启但邮件服务未配置,拒绝注册 // 如果邮件验证已开启但邮件服务未配置,拒绝注册
@@ -153,6 +178,14 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrServiceUnavailable 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 promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) {
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {

View File

@@ -115,6 +115,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E
return NewAuthService( return NewAuthService(
repo, repo,
nil, // redeemRepo
cfg, cfg,
settingService, settingService,
emailService, emailService,
@@ -152,7 +153,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi
}, nil) }, 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) require.ErrorIs(t, err, ErrServiceUnavailable)
} }
@@ -164,7 +165,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) {
SettingKeyEmailVerifyEnabled: "true", SettingKeyEmailVerifyEnabled: "true",
}, cache) }, cache)
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "") _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "")
require.ErrorIs(t, err, ErrEmailVerifyRequired) require.ErrorIs(t, err, ErrEmailVerifyRequired)
} }
@@ -178,7 +179,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) {
SettingKeyEmailVerifyEnabled: "true", SettingKeyEmailVerifyEnabled: "true",
}, cache) }, 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.ErrorIs(t, err, ErrInvalidVerifyCode)
require.ErrorContains(t, err, "verify code") require.ErrorContains(t, err, "verify code")
} }

View File

@@ -38,6 +38,7 @@ const (
RedeemTypeBalance = domain.RedeemTypeBalance RedeemTypeBalance = domain.RedeemTypeBalance
RedeemTypeConcurrency = domain.RedeemTypeConcurrency RedeemTypeConcurrency = domain.RedeemTypeConcurrency
RedeemTypeSubscription = domain.RedeemTypeSubscription RedeemTypeSubscription = domain.RedeemTypeSubscription
RedeemTypeInvitation = domain.RedeemTypeInvitation
) )
// PromoCode status constants // PromoCode status constants
@@ -71,10 +72,11 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
// Setting keys // Setting keys
const ( const (
// 注册设置 // 注册设置
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
// 邮件服务设置 // 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址

View File

@@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
return nil, errors.New("count must be greater than 0") 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") 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 codeType = RedeemTypeBalance
} }
// 邀请码类型的 value 设为 0
value := req.Value
if codeType == RedeemTypeInvitation {
value = 0
}
codes := make([]RedeemCode, 0, req.Count) codes := make([]RedeemCode, 0, req.Count)
for i := 0; i < req.Count; i++ { for i := 0; i < req.Count; i++ {
code, err := s.GenerateRandomCode() code, err := s.GenerateRandomCode()
@@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
codes = append(codes, RedeemCode{ codes = append(codes, RedeemCode{
Code: code, Code: code,
Type: codeType, Type: codeType,
Value: req.Value, Value: value,
Status: StatusUnused, Status: StatusUnused,
}) })
} }

View File

@@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyEmailVerifyEnabled, SettingKeyEmailVerifyEnabled,
SettingKeyPromoCodeEnabled, SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled, SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled, SettingKeyTotpEnabled,
SettingKeyTurnstileEnabled, SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey, SettingKeyTurnstileSiteKey,
@@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled, PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
@@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` TotpEnabled bool `json:"totp_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
@@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSiteKey: settings.TurnstileSiteKey,
@@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
// 邮件服务设置(只有非空才更新密码) // 邮件服务设置(只有非空才更新密码)
@@ -286,6 +291,15 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
return value != "false" 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 检查是否启用密码重置功能 // IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证 // 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
@@ -401,6 +415,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost], SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername], SMTPUsername: settings[SettingKeySMTPUsername],

View File

@@ -1,11 +1,12 @@
package service package service
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
@@ -61,21 +62,22 @@ type SystemSettings struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TurnstileEnabled bool TotpEnabled bool // TOTP 双因素认证
TurnstileSiteKey string TurnstileEnabled bool
SiteName string TurnstileSiteKey string
SiteLogo string SiteName string
SiteSubtitle string SiteLogo string
APIBaseURL string SiteSubtitle string
ContactInfo string APIBaseURL string
DocURL string ContactInfo string
HomeContent string DocURL string
HideCcsImportButton bool HomeContent string
HideCcsImportButton bool
PurchaseSubscriptionEnabled bool PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string PurchaseSubscriptionURL string

View File

@@ -14,6 +14,7 @@ export interface SystemSettings {
email_verify_enabled: boolean email_verify_enabled: boolean
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean
totp_enabled: boolean // TOTP 双因素认证 totp_enabled: boolean // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
// Default settings // Default settings
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
email_verify_enabled?: boolean email_verify_enabled?: boolean
promo_code_enabled?: boolean promo_code_enabled?: boolean
password_reset_enabled?: boolean password_reset_enabled?: boolean
invitation_code_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证 totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number default_balance?: number
default_concurrency?: number default_concurrency?: number

View File

@@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
return data return data
} }
/**
* Validate invitation code response
*/
export interface ValidateInvitationCodeResponse {
valid: boolean
error_code?: string
}
/**
* Validate invitation code (public endpoint, no auth required)
* @param code - Invitation code to validate
* @returns Validation result
*/
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
return data
}
/** /**
* Forgot password request * Forgot password request
*/ */
@@ -229,6 +247,7 @@ export const authAPI = {
getPublicSettings, getPublicSettings,
sendVerifyCode, sendVerifyCode,
validatePromoCode, validatePromoCode,
validateInvitationCode,
forgotPassword, forgotPassword,
resetPassword resetPassword
} }

View File

@@ -265,6 +265,13 @@ export default {
promoCodeAlreadyUsed: 'You have already used this promo code', promoCodeAlreadyUsed: 'You have already used this promo code',
promoCodeValidating: 'Promo code is being validated, please wait', promoCodeValidating: 'Promo code is being validated, please wait',
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', 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: { linuxdo: {
signIn: 'Continue with Linux.do', signIn: 'Continue with Linux.do',
orContinue: 'or continue with email', orContinue: 'or continue with email',
@@ -1899,6 +1906,8 @@ export default {
balance: 'Balance', balance: 'Balance',
concurrency: 'Concurrency', concurrency: 'Concurrency',
subscription: 'Subscription', subscription: 'Subscription',
invitation: 'Invitation',
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
unused: 'Unused', unused: 'Unused',
used: 'Used', used: 'Used',
columns: { columns: {
@@ -1945,6 +1954,7 @@ export default {
balance: 'Balance', balance: 'Balance',
concurrency: 'Concurrency', concurrency: 'Concurrency',
subscription: 'Subscription', subscription: 'Subscription',
invitation: 'Invitation',
// Admin adjustment types (created when admin modifies user balance/concurrency) // Admin adjustment types (created when admin modifies user balance/concurrency)
admin_balance: 'Balance (Admin)', admin_balance: 'Balance (Admin)',
admin_concurrency: 'Concurrency (Admin)' admin_concurrency: 'Concurrency (Admin)'
@@ -2896,6 +2906,8 @@ export default {
emailVerificationHint: 'Require email verification for new registrations', emailVerificationHint: 'Require email verification for new registrations',
promoCode: 'Promo Code', promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration', 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', passwordReset: 'Password Reset',
passwordResetHint: 'Allow users to reset their password via email', passwordResetHint: 'Allow users to reset their password via email',
totp: 'Two-Factor Authentication (2FA)', totp: 'Two-Factor Authentication (2FA)',

View File

@@ -262,6 +262,13 @@ export default {
promoCodeAlreadyUsed: '您已使用过此优惠码', promoCodeAlreadyUsed: '您已使用过此优惠码',
promoCodeValidating: '优惠码正在验证中,请稍候', promoCodeValidating: '优惠码正在验证中,请稍候',
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
invitationCodeLabel: '邀请码',
invitationCodePlaceholder: '请输入邀请码',
invitationCodeRequired: '请输入邀请码',
invitationCodeValid: '邀请码有效',
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
linuxdo: { linuxdo: {
signIn: '使用 Linux.do 登录', signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续', orContinue: '或使用邮箱密码继续',
@@ -2022,6 +2029,7 @@ export default {
balance: '余额', balance: '余额',
concurrency: '并发数', concurrency: '并发数',
subscription: '订阅', subscription: '订阅',
invitation: '邀请码',
// 管理员在用户管理页面调整余额/并发时产生的记录 // 管理员在用户管理页面调整余额/并发时产生的记录
admin_balance: '余额(管理员)', admin_balance: '余额(管理员)',
admin_concurrency: '并发数(管理员)' admin_concurrency: '并发数(管理员)'
@@ -2030,6 +2038,8 @@ export default {
balance: '余额', balance: '余额',
concurrency: '并发数', concurrency: '并发数',
subscription: '订阅', subscription: '订阅',
invitation: '邀请码',
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
allTypes: '全部类型', allTypes: '全部类型',
allStatus: '全部状态', allStatus: '全部状态',
unused: '未使用', unused: '未使用',
@@ -3049,6 +3059,8 @@ export default {
emailVerificationHint: '新用户注册时需要验证邮箱', emailVerificationHint: '新用户注册时需要验证邮箱',
promoCode: '优惠码', promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码', promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码',
passwordReset: '忘记密码', passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码', passwordResetHint: '允许用户通过邮箱重置密码',
totp: '双因素认证 (2FA)', totp: '双因素认证 (2FA)',

View File

@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => {
email_verify_enabled: false, email_verify_enabled: false,
promo_code_enabled: true, promo_code_enabled: true,
password_reset_enabled: false, password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false, turnstile_enabled: false,
turnstile_site_key: '', turnstile_site_key: '',
site_name: siteName.value, site_name: siteName.value,

View File

@@ -55,6 +55,7 @@ export interface RegisterRequest {
verify_code?: string verify_code?: string
turnstile_token?: string turnstile_token?: string
promo_code?: string promo_code?: string
invitation_code?: string
} }
export interface SendVerifyCodeRequest { export interface SendVerifyCodeRequest {
@@ -72,6 +73,7 @@ export interface PublicSettings {
email_verify_enabled: boolean email_verify_enabled: boolean
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean
turnstile_enabled: boolean turnstile_enabled: boolean
turnstile_site_key: string turnstile_site_key: string
site_name: string site_name: string
@@ -701,7 +703,7 @@ export interface UpdateProxyRequest {
// ==================== Usage & Redeem Types ==================== // ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export interface UsageLog { export interface UsageLog {
id: number id: number

View File

@@ -213,7 +213,7 @@
<Select v-model="generateForm.type" :options="typeOptions" /> <Select v-model="generateForm.type" :options="typeOptions" />
</div> </div>
<!-- 余额/并发类型显示数值输入 --> <!-- 余额/并发类型显示数值输入 -->
<div v-if="generateForm.type !== 'subscription'"> <div v-if="generateForm.type !== 'subscription' && generateForm.type !== 'invitation'">
<label class="input-label"> <label class="input-label">
{{ {{
generateForm.type === 'balance' generateForm.type === 'balance'
@@ -230,6 +230,12 @@
class="input" class="input"
/> />
</div> </div>
<!-- 邀请码类型显示提示信息 -->
<div v-if="generateForm.type === 'invitation'" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.redeem.invitationHint') }}
</p>
</div>
<!-- 订阅类型显示分组选择和有效天数 --> <!-- 订阅类型显示分组选择和有效天数 -->
<template v-if="generateForm.type === 'subscription'"> <template v-if="generateForm.type === 'subscription'">
<div> <div>
@@ -387,7 +393,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
const typeOptions = computed(() => [ const typeOptions = computed(() => [
{ value: 'balance', label: t('admin.redeem.balance') }, { value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') }, { value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') } { value: 'subscription', label: t('admin.redeem.subscription') },
{ value: 'invitation', label: t('admin.redeem.invitation') }
]) ])
const filterTypeOptions = computed(() => [ const filterTypeOptions = computed(() => [
{ value: '', label: t('admin.redeem.allTypes') }, { value: '', label: t('admin.redeem.allTypes') },
{ value: 'balance', label: t('admin.redeem.balance') }, { value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') }, { value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') } { value: 'subscription', label: t('admin.redeem.subscription') },
{ value: 'invitation', label: t('admin.redeem.invitation') }
]) ])
const filterStatusOptions = computed(() => [ const filterStatusOptions = computed(() => [
@@ -546,6 +554,18 @@ const generateForm = reactive({
validity_days: 30 validity_days: 30
}) })
// 监听类型变化,邀请码类型时自动设置 value 为 0
watch(
() => generateForm.type,
(newType) => {
if (newType === 'invitation') {
generateForm.value = 0
} else if (generateForm.value === 0) {
generateForm.value = 10
}
}
)
const loadCodes = async () => { const loadCodes = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()

View File

@@ -339,6 +339,21 @@
<Toggle v-model="form.promo_code_enabled" /> <Toggle v-model="form.promo_code_enabled" />
</div> </div>
<!-- Invitation Code -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.invitationCode')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.invitationCodeHint') }}
</p>
</div>
<Toggle v-model="form.invitation_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled --> <!-- Password Reset - Only show when email verification is enabled -->
<div <div
v-if="form.email_verify_enabled" v-if="form.email_verify_enabled"
@@ -1115,6 +1130,7 @@ const form = reactive<SettingsForm>({
registration_enabled: true, registration_enabled: true,
email_verify_enabled: false, email_verify_enabled: false,
promo_code_enabled: true, promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false, password_reset_enabled: false,
totp_enabled: false, totp_enabled: false,
totp_encryption_key_configured: false, totp_encryption_key_configured: false,
@@ -1243,6 +1259,7 @@ async function saveSettings() {
registration_enabled: form.registration_enabled, registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled, email_verify_enabled: form.email_verify_enabled,
promo_code_enabled: form.promo_code_enabled, promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
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,

View File

@@ -201,6 +201,7 @@ const email = ref<string>('')
const password = ref<string>('') 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 hasRegisterData = ref<boolean>(false) const hasRegisterData = ref<boolean>(false)
// Public settings // Public settings
@@ -230,6 +231,7 @@ onMounted(async () => {
password.value = registerData.password || '' password.value = registerData.password || ''
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 || ''
hasRegisterData.value = !!(email.value && password.value) hasRegisterData.value = !!(email.value && password.value)
} catch { } catch {
hasRegisterData.value = false hasRegisterData.value = false
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
password: password.value, password: password.value,
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
}) })
// Clear session data // Clear session data

View File

@@ -95,6 +95,59 @@
</p> </p>
</div> </div>
<!-- Invitation Code Input (Required when enabled) -->
<div v-if="invitationCodeEnabled">
<label for="invitation_code" class="input-label">
{{ t('auth.invitationCodeLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
</div>
<input
id="invitation_code"
v-model="formData.invitation_code"
type="text"
:disabled="isLoading"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
}"
:placeholder="t('auth.invitationCodePlaceholder')"
@input="handleInvitationCodeInput"
/>
<!-- Validation indicator -->
<div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
</div>
<!-- Invitation code validation result -->
<transition name="fade">
<div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
<Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" />
<span class="text-sm text-green-700 dark:text-green-400">
{{ t('auth.invitationCodeValid') }}
</span>
</div>
<p v-else-if="invitationValidation.invalid" class="input-error-text">
{{ invitationValidation.message }}
</p>
<p v-else-if="errors.invitation_code" class="input-error-text">
{{ errors.invitation_code }}
</p>
</transition>
</div>
<!-- Promo Code Input (Optional) --> <!-- Promo Code Input (Optional) -->
<div v-if="promoCodeEnabled"> <div v-if="promoCodeEnabled">
<label for="promo_code" class="input-label"> <label for="promo_code" class="input-label">
@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode } from '@/api/auth' import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
const { t } = useI18n() const { t } = useI18n()
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
const registrationEnabled = ref<boolean>(true) const registrationEnabled = ref<boolean>(true)
const emailVerifyEnabled = ref<boolean>(false) const emailVerifyEnabled = ref<boolean>(false)
const promoCodeEnabled = ref<boolean>(true) const promoCodeEnabled = ref<boolean>(true)
const invitationCodeEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false) const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API') const siteName = ref<string>('Sub2API')
@@ -280,16 +334,27 @@ const promoValidation = reactive({
}) })
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
// Invitation code validation
const invitationValidating = ref<boolean>(false)
const invitationValidation = reactive({
valid: false,
invalid: false,
message: ''
})
let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
const formData = reactive({ const formData = reactive({
email: '', email: '',
password: '', password: '',
promo_code: '' promo_code: '',
invitation_code: ''
}) })
const errors = reactive({ const errors = reactive({
email: '', email: '',
password: '', password: '',
turnstile: '' turnstile: '',
invitation_code: ''
}) })
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================
@@ -300,6 +365,7 @@ onMounted(async () => {
registrationEnabled.value = settings.registration_enabled registrationEnabled.value = settings.registration_enabled
emailVerifyEnabled.value = settings.email_verify_enabled emailVerifyEnabled.value = settings.email_verify_enabled
promoCodeEnabled.value = settings.promo_code_enabled promoCodeEnabled.value = settings.promo_code_enabled
invitationCodeEnabled.value = settings.invitation_code_enabled
turnstileEnabled.value = settings.turnstile_enabled turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API' siteName.value = settings.site_name || 'Sub2API'
@@ -325,6 +391,9 @@ onUnmounted(() => {
if (promoValidateTimeout) { if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout) clearTimeout(promoValidateTimeout)
} }
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
}) })
// ==================== Promo Code Validation ==================== // ==================== Promo Code Validation ====================
@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string {
} }
} }
// ==================== Invitation Code Validation ====================
function handleInvitationCodeInput(): void {
const code = formData.invitation_code.trim()
// Clear previous validation
invitationValidation.valid = false
invitationValidation.invalid = false
invitationValidation.message = ''
errors.invitation_code = ''
if (!code) {
return
}
// Debounce validation
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
invitationValidateTimeout = setTimeout(() => {
validateInvitationCodeDebounced(code)
}, 500)
}
async function validateInvitationCodeDebounced(code: string): Promise<void> {
invitationValidating.value = true
try {
const result = await validateInvitationCode(code)
if (result.valid) {
invitationValidation.valid = true
invitationValidation.invalid = false
invitationValidation.message = ''
} else {
invitationValidation.valid = false
invitationValidation.invalid = true
invitationValidation.message = getInvitationErrorMessage(result.error_code)
}
} catch {
invitationValidation.valid = false
invitationValidation.invalid = true
invitationValidation.message = t('auth.invitationCodeInvalid')
} finally {
invitationValidating.value = false
}
}
function getInvitationErrorMessage(errorCode?: string): string {
switch (errorCode) {
case 'INVITATION_CODE_NOT_FOUND':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_INVALID':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_USED':
return t('auth.invitationCodeInvalid')
case 'INVITATION_CODE_DISABLED':
return t('auth.invitationCodeInvalid')
default:
return t('auth.invitationCodeInvalid')
}
}
// ==================== Turnstile Handlers ==================== // ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void { function onTurnstileVerify(token: string): void {
@@ -429,6 +562,7 @@ function validateForm(): boolean {
errors.email = '' errors.email = ''
errors.password = '' errors.password = ''
errors.turnstile = '' errors.turnstile = ''
errors.invitation_code = ''
let isValid = true let isValid = true
@@ -450,6 +584,14 @@ function validateForm(): boolean {
isValid = false isValid = false
} }
// Invitation code validation (required when enabled)
if (invitationCodeEnabled.value) {
if (!formData.invitation_code.trim()) {
errors.invitation_code = t('auth.invitationCodeRequired')
isValid = false
}
}
// Turnstile validation // Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) { if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification') errors.turnstile = t('auth.completeVerification')
@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> {
} }
} }
// Check invitation code validation status (if enabled and code provided)
if (invitationCodeEnabled.value) {
// If still validating, wait
if (invitationValidating.value) {
errorMessage.value = t('auth.invitationCodeValidating')
return
}
// If invitation code is invalid, block submission
if (invitationValidation.invalid) {
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
return
}
// If invitation code is required but not validated yet
if (formData.invitation_code.trim() && !invitationValidation.valid) {
errorMessage.value = t('auth.invitationCodeValidating')
// Trigger validation
await validateInvitationCodeDebounced(formData.invitation_code.trim())
if (!invitationValidation.valid) {
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
return
}
}
}
isLoading.value = true isLoading.value = true
try { try {
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
email: formData.email, email: formData.email,
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
}) })
) )
@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
email: formData.email, email: formData.email,
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
}) })
// Show success toast // Show success toast