package service import ( "context" "crypto/rand" "encoding/json" "fmt" "math/big" "strings" "time" "carrot_bbs/internal/cache" "carrot_bbs/internal/pkg/utils" ) const ( verifyCodeTTL = 10 * time.Minute verifyCodeRateLimitTTL = 60 * time.Second ) const ( CodePurposeRegister = "register" CodePurposePasswordReset = "password_reset" CodePurposeEmailVerify = "email_verify" CodePurposeChangePassword = "change_password" ) type verificationCodePayload struct { Code string `json:"code"` Purpose string `json:"purpose"` Email string `json:"email"` ExpiresAt int64 `json:"expires_at"` } type EmailCodeService interface { SendCode(ctx context.Context, purpose, email string) error VerifyCode(purpose, email, code string) error } type emailCodeServiceImpl struct { emailService EmailService cache cache.Cache } func NewEmailCodeService(emailService EmailService, cacheBackend cache.Cache) EmailCodeService { if cacheBackend == nil { cacheBackend = cache.GetCache() } return &emailCodeServiceImpl{ emailService: emailService, cache: cacheBackend, } } func verificationCodeCacheKey(purpose, email string) string { return fmt.Sprintf("auth:verify_code:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email))) } func verificationCodeRateLimitKey(purpose, email string) string { return fmt.Sprintf("auth:verify_code_rate_limit:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email))) } func generateNumericCode(length int) (string, error) { if length <= 0 { return "", fmt.Errorf("invalid code length") } max := big.NewInt(10) result := make([]byte, length) for i := 0; i < length; i++ { n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } result[i] = byte('0' + n.Int64()) } return string(result), nil } func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email string) error { if strings.TrimSpace(email) == "" || !utils.ValidateEmail(email) { return ErrInvalidEmail } if s.emailService == nil || !s.emailService.IsEnabled() { return ErrEmailServiceUnavailable } if s.cache == nil { return ErrVerificationCodeUnavailable } rateLimitKey := verificationCodeRateLimitKey(purpose, email) if s.cache.Exists(rateLimitKey) { return ErrVerificationCodeTooFrequent } code, err := generateNumericCode(6) if err != nil { return fmt.Errorf("generate verification code failed: %w", err) } payload := verificationCodePayload{ Code: code, Purpose: purpose, Email: strings.ToLower(strings.TrimSpace(email)), ExpiresAt: time.Now().Add(verifyCodeTTL).Unix(), } cacheKey := verificationCodeCacheKey(purpose, email) s.cache.Set(cacheKey, payload, verifyCodeTTL) s.cache.Set(rateLimitKey, "1", verifyCodeRateLimitTTL) subject, sceneText := verificationEmailMeta(purpose) textBody := fmt.Sprintf("【%s】验证码:%s\n有效期:10分钟\n请勿将验证码泄露给他人。", sceneText, code) htmlBody := buildVerificationEmailHTML(sceneText, code) if err := s.emailService.Send(ctx, SendEmailRequest{ To: []string{email}, Subject: subject, TextBody: textBody, HTMLBody: htmlBody, }); err != nil { s.cache.Delete(cacheKey) return fmt.Errorf("send verification email failed: %w", err) } return nil } func (s *emailCodeServiceImpl) VerifyCode(purpose, email, code string) error { if strings.TrimSpace(email) == "" || strings.TrimSpace(code) == "" { return ErrVerificationCodeInvalid } if s.cache == nil { return ErrVerificationCodeUnavailable } cacheKey := verificationCodeCacheKey(purpose, email) raw, ok := s.cache.Get(cacheKey) if !ok { return ErrVerificationCodeExpired } var payload verificationCodePayload switch v := raw.(type) { case string: if err := json.Unmarshal([]byte(v), &payload); err != nil { return ErrVerificationCodeInvalid } case []byte: if err := json.Unmarshal(v, &payload); err != nil { return ErrVerificationCodeInvalid } case verificationCodePayload: payload = v default: data, err := json.Marshal(v) if err != nil { return ErrVerificationCodeInvalid } if err := json.Unmarshal(data, &payload); err != nil { return ErrVerificationCodeInvalid } } if payload.Purpose != purpose || payload.Email != strings.ToLower(strings.TrimSpace(email)) { return ErrVerificationCodeInvalid } if payload.ExpiresAt > 0 && time.Now().Unix() > payload.ExpiresAt { s.cache.Delete(cacheKey) return ErrVerificationCodeExpired } if payload.Code != strings.TrimSpace(code) { return ErrVerificationCodeInvalid } s.cache.Delete(cacheKey) return nil } func verificationEmailMeta(purpose string) (subject string, sceneText string) { switch purpose { case CodePurposeRegister: return "Carrot BBS 注册验证码", "注册账号" case CodePurposePasswordReset: return "Carrot BBS 找回密码验证码", "找回密码" case CodePurposeEmailVerify: return "Carrot BBS 邮箱验证验证码", "验证邮箱" case CodePurposeChangePassword: return "Carrot BBS 修改密码验证码", "修改密码" default: return "Carrot BBS 验证码", "身份验证" } } func buildVerificationEmailHTML(sceneText, code string) string { return fmt.Sprintf(`